Accordion
Disclosure & Layoutimport { Accordion } from "bits-ui" Single
type="single" keeps at most one section open, with animated height via --bits-accordion-content-height
Behaviour, focus management, ARIA wiring and keyboard support ship with the component; the markup and every class name stay yours.
Arrow keys move between triggers, Home and End jump to the first and last item, Enter and Space toggle the focused section.
Bits UI exposes --bits-accordion-content-height on the content element, so a plain CSS keyframe from 0 to that variable is enough.
<script lang="ts">
import { Accordion } from 'bits-ui';
import { ChevronDown } from '@lucide/svelte';
const items = [
{
value: 'headless',
title: 'What does “headless” actually buy me?',
content:
'Behaviour, focus management, ARIA wiring and keyboard support ship with the component; the markup and every class name stay yours.'
},
{
value: 'a11y',
title: 'Is the keyboard interaction handled?',
content:
'Arrow keys move between triggers, Home and End jump to the first and last item, Enter and Space toggle the focused section.'
},
{
value: 'anim',
title: 'How do I animate the content?',
content:
'Bits UI exposes --bits-accordion-content-height on the content element, so a plain CSS keyframe from 0 to that variable is enough.'
}
];
</script>
<Accordion.Root type="single" class="w-full">
{#each items as item (item.value)}
<Accordion.Item value={item.value} class="border-b border-ink-800">
<Accordion.Header>
<Accordion.Trigger
class="flex w-full items-center justify-between gap-4 py-4 text-left text-sm font-medium select-none hover:text-brand-400 [&[data-state=open]_svg]:rotate-180"
>
{item.title}
<ChevronDown class="size-4 shrink-0 transition-transform duration-200" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content
class="overflow-hidden text-sm text-ink-400 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
>
<p class="pb-4">{item.content}</p>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
Multiple, controlled
type="multiple" with bind:value — external buttons drive the same state
Two-way bound state for shipping. The buttons above write straight into the same $state array the accordion reads.
Two-way bound state for returns. The buttons above write straight into the same $state array the accordion reads.
Two-way bound state for support. The buttons above write straight into the same $state array the accordion reads.
<script lang="ts">
import { Accordion } from 'bits-ui';
const items = ['shipping', 'returns', 'support'];
let value = $state<string[]>(['shipping']);
const openCount = $derived(value.length);
</script>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-2">
<button class="btn btn-sm" onclick={() => (value = [...items])}>Open all</button>
<button class="btn btn-sm" onclick={() => (value = [])}>Close all</button>
<span class="chip">{openCount} open</span>
</div>
<Accordion.Root type="multiple" bind:value class="w-full">
{#each items as item (item)}
<Accordion.Item value={item} class="border-b border-ink-800">
<Accordion.Header>
<Accordion.Trigger
class="flex w-full items-center justify-between py-3 text-left text-sm font-medium capitalize select-none hover:text-brand-400"
>
{item}
<span class="muted font-mono text-xs">{value.includes(item) ? 'open' : 'closed'}</span>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="overflow-hidden text-sm text-ink-400">
<p class="pb-4">
Two-way bound state for <span class="font-mono text-ink-200">{item}</span>. The buttons
above write straight into the same <span class="font-mono text-ink-200">$state</span>
array the accordion reads.
</p>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
</div>
type is required and switches the shape of value: a string for "single", a string array for "multiple". Because a bindable prop has to be destructured, wrapper components typically spread {...restProps as any} to escape the discriminated union.