Accordion
A set of collapsible sections that reveal one panel or several at a time.
@data-slot/accordionSource ↗bun add @data-slot/accordionnpm install @data-slot/accordionpnpm add @data-slot/accordionAnatomy
One accordion-item per section, each with its own data-value — items without one are skipped. Inside an item, the trigger and content are wired together for you. accordion-trigger-icon and accordion-content-inner are optional styling hooks.
<div data-slot="accordion">
<div data-slot="accordion-item" data-value="unique-id">
<button data-slot="accordion-trigger">
<span>Trigger</span>
<span data-slot="accordion-trigger-icon">+</span>
</button>
<div data-slot="accordion-content">
<div data-slot="accordion-content-inner">Content</div>
</div>
</div>
</div>
Examples
Single panel
data-slot attributes to your markup and call create(). No framework, no virtual DOM, no build step required.data-slot attributes to your markup and call create(). No framework, no virtual DOM, no build step required.Show code
<div data-slot="accordion" data-default-value="features">
<div data-slot="accordion-item" data-value="features" class="accordion-item">
<button data-slot="accordion-trigger" class="accordion-trigger">
<span>What makes data-slot different?</span>
<span data-slot="accordion-trigger-icon" class="accordion-trigger-icon">+</span>
</button>
<div data-slot="accordion-content" class="accordion-content">
<div data-slot="accordion-content-inner" class="accordion-content-inner">
Unlike React-based headless libraries, data-slot works with vanilla HTML.
</div>
</div>
</div>
<div data-slot="accordion-item" data-value="accessibility" class="accordion-item">
<button data-slot="accordion-trigger" class="accordion-trigger">
<span>Is it accessible?</span>
<span data-slot="accordion-trigger-icon" class="accordion-trigger-icon">+</span>
</button>
<div data-slot="accordion-content" class="accordion-content">
<div data-slot="accordion-content-inner" class="accordion-content-inner">
Yes. All components implement WAI-ARIA patterns.
</div>
</div>
</div>
</div>
<style>
.accordion-item { border-bottom: 1px dashed var(--border); }
.accordion-item:last-child { border-bottom: none; }
.accordion-trigger {
width: 100%;
padding: 0.75rem 0;
background: none;
border: none;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
}
.accordion-trigger-icon {
margin-left: auto;
color: var(--muted);
transition: transform 0.25s ease;
}
.accordion-item[data-state="open"] .accordion-trigger-icon {
transform: rotate(45deg);
}
.accordion-content {
overflow: hidden;
height: var(--accordion-panel-height);
transition: height 0.3s ease-out;
}
.accordion-content[data-starting-style] {
height: 0;
}
.accordion-content-inner { padding: 0 0 1rem; color: var(--muted); }
</style><div data-slot="accordion" data-default-value="features">
<div
data-slot="accordion-item"
data-value="features"
class="group border-b border-dashed border-[var(--border)] last:border-b-0"
>
<button
data-slot="accordion-trigger"
class="flex w-full items-center gap-4 bg-transparent py-3 text-left"
>
<span>What makes data-slot different?</span>
<span
data-slot="accordion-trigger-icon"
class="ml-auto shrink-0 text-[var(--muted)] transition-transform group-data-[state=open]:rotate-45"
>
+
</span>
</button>
<div
data-slot="accordion-content"
class="overflow-hidden h-(--accordion-panel-height) transition-all duration-300 ease-out data-starting-style:h-0"
>
<div
data-slot="accordion-content-inner"
class="pb-4 pt-0 text-[var(--muted)]"
>
Unlike React-based headless libraries, data-slot works with vanilla HTML.
</div>
</div>
</div>
<div
data-slot="accordion-item"
data-value="accessibility"
class="group border-b border-dashed border-[var(--border)] last:border-b-0"
>
<button
data-slot="accordion-trigger"
class="flex w-full items-center gap-4 bg-transparent py-3 text-left"
>
<span>Is it accessible?</span>
<span
data-slot="accordion-trigger-icon"
class="ml-auto shrink-0 text-[var(--muted)] transition-transform group-data-[state=open]:rotate-45"
>
+
</span>
</button>
<div
data-slot="accordion-content"
class="overflow-hidden h-(--accordion-panel-height) transition-all duration-300 ease-out data-starting-style:h-0"
>
<div
data-slot="accordion-content-inner"
class="pb-4 pt-0 text-[var(--muted)]"
>
Yes. All components implement WAI-ARIA patterns.
</div>
</div>
</div>
</div>import { create } from "@data-slot/accordion";
// Initialize after the markup is in the document.
const controllers = create();
// Clean up before removing the component.
// controllers.forEach((controller) => controller.destroy());Multiple panels
Add data-multiple to let several sections stay open at once.
multiple: true, multiple items can be open simultaneously.multiple: true, multiple items can be open simultaneously.Show code
<div data-slot="accordion" data-multiple>
<div data-slot="accordion-item" data-value="item1" class="accordion-item">
<button data-slot="accordion-trigger" class="accordion-trigger">
<span>First item</span>
<span data-slot="accordion-trigger-icon" class="accordion-trigger-icon">+</span>
</button>
<div data-slot="accordion-content" class="accordion-content">
<div data-slot="accordion-content-inner" class="accordion-content-inner">
With <code>multiple: true</code>, multiple items can be open simultaneously.
</div>
</div>
</div>
<div data-slot="accordion-item" data-value="item2" class="accordion-item">
<button data-slot="accordion-trigger" class="accordion-trigger">
<span>Second item</span>
<span data-slot="accordion-trigger-icon" class="accordion-trigger-icon">+</span>
</button>
<div data-slot="accordion-content" class="accordion-content">
<div data-slot="accordion-content-inner" class="accordion-content-inner">
Try clicking both items — they stay open independently.
</div>
</div>
</div>
</div>
<style>
.accordion-item { border-bottom: 1px dashed var(--border); }
.accordion-item:last-child { border-bottom: none; }
.accordion-trigger {
width: 100%;
padding: 0.75rem 0;
background: none;
border: none;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
}
.accordion-trigger-icon {
margin-left: auto;
color: var(--muted);
transition: transform 0.25s ease;
}
.accordion-item[data-state="open"] .accordion-trigger-icon {
transform: rotate(45deg);
}
.accordion-content {
overflow: hidden;
height: var(--accordion-panel-height);
transition: height 0.3s ease-out;
}
.accordion-content[data-starting-style] {
height: 0;
}
.accordion-content-inner { padding: 0 0 1rem; color: var(--muted); }
</style><div data-slot="accordion" data-multiple>
<div
data-slot="accordion-item"
data-value="item1"
class="group border-b border-dashed border-[var(--border)] last:border-b-0"
>
<button
data-slot="accordion-trigger"
class="flex w-full items-center gap-4 bg-transparent py-3 text-left"
>
<span>First item</span>
<span
data-slot="accordion-trigger-icon"
class="ml-auto shrink-0 text-[var(--muted)] transition-transform group-data-[state=open]:rotate-45"
>
+
</span>
</button>
<div
data-slot="accordion-content"
class="overflow-hidden h-(--accordion-panel-height) transition-all duration-300 ease-out data-starting-style:h-0"
>
<div
data-slot="accordion-content-inner"
class="pb-4 pt-0 text-[var(--muted)]"
>
With <code>multiple: true</code>, multiple items can be open simultaneously.
</div>
</div>
</div>
<div
data-slot="accordion-item"
data-value="item2"
class="group border-b border-dashed border-[var(--border)] last:border-b-0"
>
<button
data-slot="accordion-trigger"
class="flex w-full items-center gap-4 bg-transparent py-3 text-left"
>
<span>Second item</span>
<span
data-slot="accordion-trigger-icon"
class="ml-auto shrink-0 text-[var(--muted)] transition-transform group-data-[state=open]:rotate-45"
>
+
</span>
</button>
<div
data-slot="accordion-content"
class="overflow-hidden h-(--accordion-panel-height) transition-all duration-300 ease-out data-starting-style:h-0"
>
<div
data-slot="accordion-content-inner"
class="pb-4 pt-0 text-[var(--muted)]"
>
Try clicking both items — they stay open independently.
</div>
</div>
</div>
</div>
import { create } from "@data-slot/accordion";
// Initialize after the markup is in the document.
const controllers = create();
// Clean up before removing the component.
// controllers.forEach((controller) => controller.destroy());API reference
Initialization
create(scope?)
Auto-discover and bind all accordion instances in a scope (defaults to document).
import { create } from "@data-slot/accordion";
const controllers = create(); // Returns AccordionController[]
createAccordion(root, options?)
Create a controller for a specific element.
import { createAccordion } from "@data-slot/accordion";
const accordion = createAccordion(element, {
multiple: true,
defaultValue: ["one"],
orientation: "vertical",
loopFocus: true,
hiddenUntilFound: false,
onValueChange: (values) => console.log(values),
});
Slots
Runtime Slots
accordion- Root element that manages expanded items and keyboard navigation.accordion-item- Container for one section. Set a uniquedata-valueto identify it; at least one item is required.accordion-trigger- Button inside an item that toggles its content and receivesaria-expanded.accordion-content- Panel inside an item, linked to its trigger and shown when the item is expanded.
Style-only Slots
accordion-trigger-icon- Optional icon inside the trigger, useful for rotating with the open state.accordion-content-inner- Optional wrapper for content padding inside the animated panel.
Markup
<div data-slot="accordion">
<div data-slot="accordion-item" data-value="unique-id">
<button data-slot="accordion-trigger">
<span>Trigger</span>
<span data-slot="accordion-trigger-icon">+</span>
</button>
<div data-slot="accordion-content">
<div data-slot="accordion-content-inner">Content</div>
</div>
</div>
</div>
Data Attributes
Options can also be set via data attributes on the root element. JS options take precedence.
| Attribute | Type | Default | Description |
|---|---|---|---|
data-multiple |
boolean |
false |
Allow multiple items open at once |
data-default-value |
string |
none | Initially expanded item, or a JSON array string for multiple defaults |
data-disabled |
boolean |
false |
Disable the entire accordion |
data-orientation |
"horizontal" | "vertical" |
"vertical" |
Controls roving-focus arrow keys |
data-loop-focus |
boolean |
true |
Wrap roving focus at the ends |
data-hidden-until-found |
boolean |
false |
Use hidden="until-found" on closed panels |
data-collapsible |
boolean |
true |
Deprecated single-mode compatibility alias |
Boolean attributes: present or "true" = true, "false" = false, absent = default.
<div
data-slot="accordion"
data-multiple
data-default-value="one"
data-orientation="horizontal"
>
...
</div>
For multiple default items in HTML, encode the value as JSON:
<div
data-slot="accordion"
data-multiple
data-default-value='["one","two"]'
>
...
</div>
Options
| Option | Type | Default | Description |
|---|---|---|---|
multiple |
boolean |
false |
Allow multiple items open at once |
defaultValue |
string | string[] |
undefined |
Initially expanded item(s) |
disabled |
boolean |
false |
Disable all user interaction for the accordion |
orientation |
"horizontal" | "vertical" |
"vertical" |
Controls roving-focus arrow keys |
loopFocus |
boolean |
true |
Wrap roving focus at the ends |
hiddenUntilFound |
boolean |
false |
Use hidden="until-found" on closed panels |
onValueChange |
(value: string[]) => void |
undefined |
Callback when expanded items change |
collapsible |
boolean |
true |
Deprecated single-mode alias for “can close the last open item” |
Deprecated Option
The following option is deprecated and will be removed in the next major release:
createAccordion(element, {
// Deprecated: use the default Base UI-style collapsible behavior instead.
collapsible: false,
});
Controller
| Method/Property | Description |
|---|---|
expand(value) |
Expand an item by value |
collapse(value) |
Collapse an item by value |
toggle(value) |
Toggle an item by value |
value |
Currently expanded values (readonly string[]) |
destroy() |
Cleanup all event listeners |
Events
Outbound Events
Listen for changes via custom events:
element.addEventListener("accordion:change", (e) => {
console.log("Expanded items:", e.detail.value);
});
Inbound Events
Control the accordion via events:
| Event | Detail | Description |
|---|---|---|
accordion:set |
{ value: string | string[] } |
Set expanded items programmatically |
element.dispatchEvent(
new CustomEvent("accordion:set", { detail: { value: "one" } })
);
element.dispatchEvent(
new CustomEvent("accordion:set", { detail: { value: ["one", "two"] } })
);
accordion:set and controller methods still work when the accordion is disabled. User-triggered click and keyboard interaction do not.
Styling
State Hooks
The accordion exposes these useful styling hooks:
| Element | Hooks |
|---|---|
| root | data-disabled, data-orientation |
| item | data-state, data-open, data-closed, data-index, data-disabled |
| trigger | data-state, data-panel-open, data-disabled, aria-expanded |
| content | data-state, data-open, data-closed, data-index, data-disabled, data-orientation, data-starting-style, data-ending-style |
CSS Variables
The content element exposes size variables for height or width transitions:
| Variable | Description |
|---|---|
--accordion-panel-height |
Panel height (auto at rest, measured px during transitions, 0px when closed) |
--accordion-panel-width |
Panel width (auto at rest, measured px during transitions, 0px when closed) |
--radix-accordion-content-height |
Compatibility alias for Tailwind/Radix accordion keyframes |
--radix-accordion-content-width |
Compatibility alias for width-based integrations |
CSS Example
[data-slot="accordion-item"] {
border-bottom: 1px solid #e5e7eb;
}
[data-slot="accordion-trigger"] {
width: 100%;
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: transparent;
border: 0;
text-align: left;
}
[data-slot="accordion-trigger-icon"] {
margin-left: auto;
width: 1rem;
height: 1rem;
color: #6b7280;
transition: transform 0.2s ease;
}
[data-slot="accordion-item"][data-state="open"] [data-slot="accordion-trigger-icon"] {
transform: rotate(45deg);
}
[data-slot="accordion-content"] {
overflow: hidden;
height: var(--accordion-panel-height);
transition: height 0.2s ease;
}
[data-slot="accordion-content"][data-starting-style] {
height: 0;
}
[data-slot="accordion-content-inner"] {
padding: 0 1rem 1rem;
}
Tailwind Example
<div data-slot="accordion" class="overflow-hidden border">
<div
data-slot="accordion-item"
data-value="one"
class="group border-b data-[open]:bg-muted/50"
>
<button
data-slot="accordion-trigger"
class="flex w-full items-center gap-6 p-4 text-left text-sm font-medium hover:underline"
>
<span>Section One</span>
<svg
data-slot="accordion-trigger-icon"
viewBox="0 0 12 12"
class="ml-auto size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-45"
>
<path d="M6.75 0H5.25V5.25H0V6.75H5.25V12H6.75V6.75H12V5.25H6.75V0Z" />
</svg>
</button>
<div
data-slot="accordion-content"
class="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
>
<div
data-slot="accordion-content-inner"
class="h-(--accordion-panel-height) px-4 pb-4 pt-0 data-ending-style:h-0 data-starting-style:h-0"
>
Content
</div>
</div>
</div>
</div>
Keyboard Navigation
| Key | Action |
|---|---|
Enter / Space |
Toggle focused item |
ArrowDown / ArrowUp |
Move focus in vertical accordions |
ArrowRight / ArrowLeft |
Move focus in horizontal accordions |
Home |
Move focus to first enabled trigger |
End |
Move focus to last enabled trigger |
Disabled items are skipped during roving focus.