# Accordion

> Native <details> items that join into a list; a shared name keeps one open, the ghost look shows and hides one section.

Page: https://svelte-lean.vercel.app/primitives/accordion · Markdown: https://svelte-lean.vercel.app/primitives/accordion.md · Index: https://svelte-lean.vercel.app/llms.txt

Native details elements that show and hide sections: adjacent items join into a list, a shared name keeps one open.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/accordion.css';
</script>

<details data-slean="accordion" name="faq" open>
	<summary>Shipping</summary>
	Orders ship within two working days from the Rotterdam warehouse.
</details>
<details data-slean="accordion" name="faq">
	<summary>Returns</summary>
	Thirty days, with the original packaging.
</details>
<details data-slean="accordion" name="faq">
	<summary>Warranty</summary>
	Two years on parts and labour, from the date of delivery.
</details>
```

The items share a name, so opening one closes the other. Works with JavaScript disabled.

## Examples

### Several open

```svelte
<script lang="ts">
	import '@svelte-lean/styles/accordion.css';
</script>

<!-- No name: every item opens and closes on its own. -->
<details data-slean="accordion" open>
	<summary>Notifications</summary>
	Email for mentions and replies; nothing else.
</details>
<details data-slean="accordion" open>
	<summary>Privacy</summary>
	Your profile is visible to your team only.
</details>
<details data-slean="accordion">
	<summary>Sessions</summary>
	Two browsers are signed in.
</details>
```

Without a shared name, each item opens and closes on its own.

### Ghost

```svelte
<script lang="ts">
	import '@svelte-lean/styles/accordion.css';
</script>

<details data-slean="accordion" data-variant="ghost">
	<summary>Why is the delivery date a range?</summary>
	The carrier confirms the day once the parcel leaves the warehouse. Until then, the range
	covers the shortest and the longest route.
</details>
```

The ghost variant drops the border and the surface: one show-and-hide section in running content.

### In a form

```svelte
<script lang="ts">
	import '@svelte-lean/styles/accordion.css';
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
</script>

<div data-slean="field">
	<label for="accordion-repo" data-slean-part="label">Repository name</label>
	<input id="accordion-repo" name="repository" value="harbour-survey" data-slean="input" />
</div>

<!-- Fields in a closed item are still submitted with the form. -->
<details data-slean="accordion" data-variant="ghost">
	<summary>Advanced settings</summary>
	<div data-slean-part="panel" class="stack">
		<div data-slean="field">
			<label for="accordion-branch" data-slean-part="label">Default branch</label>
			<input id="accordion-branch" name="branch" value="main" data-slean="input" />
		</div>
		<div data-slean="field">
			<label for="accordion-topics" data-slean-part="label">Topics</label>
			<input id="accordion-topics" name="topics" placeholder="maps, tides" data-slean="input" />
		</div>
	</div>
</details>

<style>
	.stack {
		display: grid;
		gap: 1rem;
	}
</style>
```

Fields inside a closed item are still submitted with the form.

### Expand and collapse all

```svelte
<script lang="ts">
	import '@svelte-lean/styles/accordion.css';
	import '@svelte-lean/styles/button.css';

	const sections = [
		{ id: 'install', title: 'Install', text: 'Add the packages with your package manager.' },
		{ id: 'configure', title: 'Configure', text: 'Register the Vite plugin in vite.config.ts.' },
		{ id: 'deploy', title: 'Deploy', text: 'Build once; the output is static HTML and CSS.' }
	];
	let open: Record<string, boolean> = $state({ install: true, configure: false, deploy: false });

	function setAll(value: boolean) {
		for (const section of sections) open[section.id] = value;
	}
</script>

<div class="actions">
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="small" onclick={() => setAll(true)}>
		Expand all
	</button>
	<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-size="small" onclick={() => setAll(false)}>
		Collapse all
	</button>
</div>
{#each sections as section (section.id)}
	<details data-slean="accordion" bind:open={open[section.id]}>
		<summary>{section.title}</summary>
		{section.text}
	</details>
{/each}
```

bind:open on each item; two buttons write every value.

### Rich content

```svelte
<script lang="ts">
	import '@svelte-lean/styles/accordion.css';
	import '@svelte-lean/styles/checkbox.css';
</script>

<details data-slean="accordion" name="settings" open>
	<summary>Notifications</summary>
	<div data-slean-part="panel" class="stack">
		<label><input type="checkbox" role="switch" data-slean="checkbox" checked /> Mentions</label>
		<label><input type="checkbox" role="switch" data-slean="checkbox" /> Weekly digest</label>
	</div>
</details>
<details data-slean="accordion" name="settings">
	<summary>Plan</summary>
	<div data-slean-part="panel">
		<p>Team plan, renews on 1 March.</p>
		<ul>
			<li>Ten seats</li>
			<li>Audit log</li>
		</ul>
	</div>
</details>
<details data-slean="accordion" name="settings">
	<summary>Help</summary>
	Read the <a href="/docs">documentation</a> or write to the support team.
</details>

<style>
	.stack {
		display: grid;
		gap: 0.5rem;
	}
</style>
```

An item takes any markup; the panel part wraps content that needs one element.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/accordion.css';
```

Put `data-slean="accordion"` on each `<details>`. Items that are adjacent siblings join into one list; anything between them starts a new list.

Give every group its own `name`: the browser groups all `<details>` of the document that share a value. Render `open` on at most one item of a group. Leave `name` off when several items may be open.

Text can follow the summary directly; the item pads it. Wrap content that needs one element (a grid of fields) in `data-slean-part="panel"`.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<details data-slean="accordion">` | – | yes | One per item, no wrapper. Adjacent items join into one list; the same name on every item keeps one open; data-variant="ghost" is the bare look. |
| summary | `<summary>` | – | yes | The first child of each item; the header the reader activates. |
| panel | `one element after the summary` | `panel` | no | Wraps content that needs one element. The item pads its content and animates its height without it. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Enter`/`Space` | focus on a summary | Toggles its item (native) |
| `Tab`/`Shift+Tab` | anywhere | Moves between summaries and into open content (native) |

## Styling

`accordion.css` draws each item's border and surface, joins adjacent items (one shared border, the outer corners rounded once), the summary row with its hover fill and inset focus ring, the chevron, the content padding, and the height transition on `::details-content` where `interpolate-size` is supported. The ghost variant (`data-variant`) drops the border, the surface and the inline padding. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | padding-inline, margin-inline, padding-block-end |
| `--slean-border` | `var(--slean-neutral-6)` | border, border-block-start |
| `--slean-radius-lg` | `calc(var(--slean-radius) * 1.4)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | gap |
| `--slean-control-height-lg` | `2.75rem` | min-block-size |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | padding-block |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-icon-chevron-down` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-duration-normal` | `160ms` | transition |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-focus-ring-width` | `2px` | outline-offset |
| `--slean-muted` | `var(--slean-neutral-3)` | background |
| `--slean-control-height-md` | `2.25rem` | min-block-size |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-focus-ring-offset` | `2px` | outline-offset |

State selectors the stylesheet targets, all from the platform or ARIA: `::details-content`, `:focus-visible`, `:hover`, `[open]`.

Variant attributes: `data-variant` (`ghost`).

## Controlled integration

The open attribute of each item is its state. Bind it per item; an item closed by its exclusive sibling fires `toggle`, and the binding follows.

`faq.svelte`

```svelte
<script lang="ts">
	// Each item's open attribute is its state; bind it. Opening one item of the group closes
	// the other, and Svelte's binding follows through the toggle event.
	const items = [
		{ id: 'shipping', title: 'Shipping', text: 'Orders ship within two working days.' },
		{ id: 'returns', title: 'Returns', text: 'Thirty days, with the original packaging.' }
	];
	let open: Record<string, boolean> = $state({ shipping: true, returns: false });
	const current = $derived(items.find((item) => open[item.id])?.title ?? 'none');
</script>

{#each items as item (item.id)}
	<details data-slean="accordion" name="checkout-faq" bind:open={open[item.id]}>
		<summary>{item.title}</summary>
		{item.text}
	</details>
{/each}
<p>Open: {current}</p>
```

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<details name>`, `<summary>`, `toggle`, `::details-content`, `interpolate-size`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<details> + <summary>, exclusive groups through name`

Tier 0: no behavior module. The Vite plugin maps `accordion` to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript.

Why this implementation

A disclosure is a button that shows and hides a region and announces its expanded state; an accordion is a list of them where opening one closes the others. The platform has both: `<details>` and `<summary>` are the disclosure, and `<details>` elements with the same `name` form an exclusive group. Enter and Space toggle an item, each summary is announced as a button with its expanded state, and find-in-page opens the item that holds a match. None of it needs a script.

So the primitive is the attribute on each item, with no wrapper: the stylesheet joins adjacent items into one bordered list, and `data-variant="ghost"` draws a single section bare. One element and one name for both uses (ADR 0011 §1.5).

### The browser owns

- the open state of every item (the open attribute) and the toggle event
- closing the open item when another item with the same name opens
- Enter and Space on each summary, and the summary as a button with an expanded state
- opening a closed item when find-in-page matches its content

### Svelte Lean owns

- accordion.css: joining adjacent items into a bordered list, the ghost look, the summary row, the chevron, the content padding and the height transition
- the contract: one name per accordion, at most one item rendered open per name
- documentation

Accessibility

- Each `<summary>` is exposed as a button with an expanded state; no ARIA attribute is needed.
- Enter and Space toggle an item; Tab moves between summaries and into open content. The optional arrow-key movement of the WAI-ARIA accordion pattern is not provided.
- A heading inside `<summary>` is valid HTML, but screen readers differ in whether they still expose it as a heading, so the contract does not require one.
- `<details>` has no disabled state; render content that must stay visible outside the accordion.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<details>, <summary>, the toggle event` | Widely available | Not applicable within the support policy |
| `name for exclusive groups` | Newly available since September 2024 (Chrome 120, Safari 17.2, Firefox 130) | Every item opens on its own |
| `Find-in-page opens a closed item` | Chrome 97, Firefox 139, Safari 26.2 | The match inside a closed item is not found |
| `::details-content` | Newly available since September 2025 (Chrome 131, Safari 18.4, Firefox 143) | The open item carries the content padding; no transition |
| `interpolate-size: allow-keywords` | Chrome 129; not in Firefox or Safari | The content appears without a transition |

Without JavaScript

Fully functional, exclusive groups included: the browser reads the name attribute.

Server rendering and hydration

Static HTML. Render `open` on the item that starts expanded; the server output is the final state.

Nothing is attached. An item the reader opens before hydration stays open after it.

Compatibility

Exclusive groups are Baseline since September 2024 (Chrome 120, Safari 17.2, Firefox 130); in older browsers every item opens on its own. Find-in-page opens a closed item in Chrome 97, Firefox 139 and Safari 26.2. The height animates only where `interpolate-size` exists (Chrome 129, not Firefox or Safari); elsewhere the content appears without a transition.

Tests and source

- `apps/playground/tests/primitives/display.spec.ts` Playwright: open state, exclusive groups, stretched links, roles and names, carousel scrolling, axe
- `apps/playground/tests/primitives/native.spec.ts` Playwright, with page JavaScript enabled and disabled
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/accordion/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/accordion/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/accordion.css` the optional stylesheet
