AccordionATM
Collapsible content sections built on native <details> / <summary>. Multi-open needs zero JS. Single-open adds a small toggle handler.
On this page (15)
§Multi-open
Default behavior - multiple items can be open simultaneously. Uses native <details>/<summary> with zero JavaScript.
§Single-open
Only one item can be open at a time. Add data-type='single' to the wrapper - a small JS handler closes siblings on toggle.
§Surfaces
data-variant on the .accordion: bordered (one box), separated (a card per item), ghost (no dividers). Boxed variants inset the text and tint the row on hover.
§Colors
muted, primary and neutral put every item on that surface; highlight keeps items plain and turns the open one primary. Heading, marker and content follow the surface's text color.
§Custom colors
Set background and color on an item - its hover tint, marker and content text derive from that color.
§Icons and emojis
An .accordion-icon in front of each heading - an icon or an emoji in a fixed box, so the headings line up.
§Arrow and plus markers
data-marker='arrow' / 'plus' on the .accordion draws the sign for every item - no icon markup; data-marker-position='start' puts it before the heading (a FAQ, a file tree).
§Custom open/close signs
Own glyphs: the .accordion-chevron turns 180° (90° with data-turn='quarter' for a chevron-right) - or two elements, .accordion-when-closed and .accordion-when-open, swap.
§Right to left
Logical throughout: in dir='rtl' the icon leads on the right, the marker sits on the left.
§Density
Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.
§States
Named states via the shared State API, driven per instance through the bound api:
default- the authored markup, snapshotted when the component initializesall-open- every<details>item expanded (single-open enforcement suspended)all-closed- every item collapsed, even withoutdata-collapsible
The single-open demo carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/accordion-{state}.png.
Machine contract - verified against accordion.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
all-open | boolean | true, false | false | Every .accordion-item opened in one batch. |
all-closed | boolean | true, false | false | Every .accordion-item closed in one batch. |
§API
Generated from accordion.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type AccordionState = 'default' | 'all-open' | 'all-closed' - setState(name, config) takes the config of the state it names.
| State | Description |
|---|---|
default | The items open as authored (each <details open>). No config. |
all-open | Every item open. No config. |
all-closed | Every item closed. No config. |
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends AccordionState>(name: S, config?: AccordionStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: AccordionState; config: AccordionStateConfigs[AccordionState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: AccordionState; config: AccordionStateConfigs[AccordionState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: AccordionState; config: AccordionStateConfigs[AccordionState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.accordionApi.setState<S extends AccordionState>(el: HTMLElement, name: S, config?: AccordionStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.accordionApi.getState(el: HTMLElement): { name: AccordionState; config: AccordionStateConfigs[AccordionState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.accordionApi.render(state: { name: AccordionState; config: AccordionStateConfigs[AccordionState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.accordionApi.store(el: HTMLElement): Store<{ name: AccordionState; config: AccordionStateConfigs[AccordionState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.accordionApi.commit<S extends AccordionState>(el: HTMLElement, name: S, config?: AccordionStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.accordionStates: AccordionState[] | The declared states, 'default' first: default, all-open, all-closed. |
§CSS view file
/* -- Accordion component --------------------------------------- */@layer components { .accordion { display: flex; flex-direction: column; } .accordion-item { border-bottom: 1px solid var(--border); &:last-child { border-bottom: none; } /* -- Content animation (CSS-only) ------------------------- Two gotchas, both required for the panel to glide: 1. `::details-content` must attach to the compound (`&::details-content`, NOT `& ::details-content` - the descendant form never matches, the pseudo's originating element is the subject itself). 2. `block-size: auto` is a keyword - without interpolate-size the 0→auto pair is non-interpolable and the transition silently snaps. */ &::details-content { block-size: 0; overflow-y: clip; interpolate-size: allow-keywords; transition: block-size 200ms ease, content-visibility 200ms allow-discrete; } &[open]::details-content { block-size: auto; } } @starting-style { .accordion-item[open]::details-content { block-size: 0; } } /* Remove default details marker */ .accordion-trigger { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 1rem 0; font-size: 0.9375rem; font-weight: 500; text-align: start; cursor: pointer; list-style: none; color: inherit; transition: color 150ms; &::-webkit-details-marker { display: none; } &::marker { content: ''; } &:hover { text-decoration: underline; } } /* Chevron rotation */ .accordion-chevron { margin-inline-start: auto; color: color-mix(in oklch, currentColor 60%, transparent); transition: transform 200ms ease; flex-shrink: 0; details[open] > .accordion-trigger & { transform: rotate(180deg); } } /* Content */ .accordion-content { padding-bottom: 1rem; font-size: 0.875rem; line-height: 1.7; color: color-mix(in oklch, currentColor 72%, transparent); overflow: hidden; & p { margin: 0; } } /* -- Surfaces (data-variant on the .accordion) ----------------------- bordered: one box, items divided · separated: every item its own card · muted / primary / neutral: separated items on that surface · highlight: separated, the open item turns primary · ghost: no dividers. Boxed variants pad the text inside and swap the underline hover for a tint. The item's text color flows into the heading, the marker and the content, so custom colors (style on an item) just work. */ .accordion { color: var(--foreground); &[data-variant="bordered"] { border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; } &:is([data-variant="separated"], [data-variant="muted"], [data-variant="primary"], [data-variant="neutral"], [data-variant="highlight"]) { gap: 0.5rem; & > .accordion-item { border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; } } &[data-variant="muted"] > .accordion-item { background-color: var(--muted); border-color: transparent; } &[data-variant="primary"] > .accordion-item { background-color: var(--primary); color: var(--primary-foreground); border-color: transparent; } &[data-variant="neutral"] > .accordion-item { background-color: color-mix(in oklch, var(--foreground) 78%, var(--background)); color: var(--background); border-color: transparent; } &[data-variant="highlight"] > .accordion-item { transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease; &[open] { background-color: var(--primary); color: var(--primary-foreground); border-color: transparent; } } &[data-variant="ghost"] > .accordion-item { border-bottom-color: transparent; } /* boxed: text inset, tint on hover */ &:is([data-variant="bordered"], [data-variant="separated"], [data-variant="muted"], [data-variant="primary"], [data-variant="neutral"], [data-variant="highlight"]) { & .accordion-trigger { padding-inline: 1rem; &:hover { text-decoration: none; background-color: color-mix(in oklch, currentColor 7%, transparent); } } & .accordion-content { padding-inline: 1rem; } } /* -- Sizes: the headings' scale and weight (md == default) ---------- */ &[data-size="sm"] .accordion-trigger { font-size: 0.8125rem; } &[data-size="md"] .accordion-trigger { font-size: 0.9375rem; } &[data-size="lg"] .accordion-trigger { font-size: 1.0625rem; font-weight: 600; } &[data-size="xl"] .accordion-trigger { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.01em; } } /* -- Leading icon / emoji ------------------------------------------- */ .accordion-icon { display: inline-grid; place-items: center; width: 1.15em; height: 1.15em; flex-shrink: 0; font-style: normal; line-height: 1; & svg { width: 1em; height: 1em; } } /* -- Markers: data-marker="arrow" / "plus" on the .accordion draws the open/close sign (no icon markup); data-marker-position="start" puts it before the heading. Own glyphs: .accordion-chevron (180°, or 90° with data-turn="quarter" - mirrored in RTL) or two elements .accordion-when-closed / .accordion-when-open. */ .accordion:is([data-marker="arrow"], [data-marker="plus"]) .accordion-trigger::after { content: ''; flex-shrink: 0; margin-inline-start: auto; color: color-mix(in oklch, currentColor 65%, transparent); transition: rotate 200ms ease, translate 200ms ease, background-size 200ms ease; } .accordion[data-marker="arrow"] .accordion-trigger::after { width: 0.45em; height: 0.45em; margin-inline-end: 0.2em; /* physical: a down / up arrow is the same in RTL */ border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; rotate: 45deg; translate: 0 -0.15em; } .accordion[data-marker="arrow"] .accordion-item[open] > .accordion-trigger::after { rotate: -135deg; translate: 0 0.1em; } .accordion[data-marker="plus"] .accordion-trigger::after { width: 0.8em; height: 0.8em; background: linear-gradient(currentColor 0 0) center / 100% 2px no-repeat, linear-gradient(currentColor 0 0) center / 2px 100% no-repeat; } .accordion[data-marker="plus"] .accordion-item[open] > .accordion-trigger::after { background-size: 100% 2px, 2px 0; rotate: 180deg; } .accordion[data-marker-position="start"] .accordion-trigger::after { order: -1; margin-inline-start: 0; margin-inline-end: 0.25em; } .accordion-chevron[data-turn="quarter"] { details[open] > .accordion-trigger > & { transform: rotate(90deg); } &:dir(rtl) { scale: -1 1; } details[open] > .accordion-trigger > &:dir(rtl) { transform: rotate(-90deg); } } .accordion-item:not([open]) > .accordion-trigger .accordion-when-open, .accordion-item[open] > .accordion-trigger .accordion-when-closed { display: none; } .accordion-trigger > :is(.accordion-when-open, .accordion-when-closed) { margin-inline-start: auto; } /* -- Density ---------------------------------------------------- data-density on the .accordion root scales trigger + panel padding. comfortable (1rem) == the unsized default. Ratio mirrors sizing.css. */ .accordion:where([data-density="compact"]) { & .accordion-trigger { padding: 0.75rem 0; } & .accordion-content { padding-bottom: 0.75rem; } } .accordion:where([data-density="comfortable"]) { & .accordion-trigger { padding: 1rem 0; } & .accordion-content { padding-bottom: 1rem; } } .accordion:where([data-density="spacious"]) { & .accordion-trigger { padding: 1.25rem 0; } & .accordion-content { padding-bottom: 1.25rem; } }}/* Accessibility: reduced motion suppresses the expand/collapse animation (REQUIRED for all components - AGENTS.md "Accessibility CSS"). */@media (prefers-reduced-motion: reduce) { @layer components { .accordion-item::details-content { transition: none; } .accordion-trigger { transition: none; } }}§JavaScript view file
Only needed for single-open mode (when data-type="single" is set). Multi-open accordions use zero JavaScript - native <details> handles everything.
// -- Accordion -----------------------------------------------// Single-open accordion behavior using native <details> elements, plus the// named-state API so agents/tests can drive states by name (AGENTS.md "State// API"), and render(): the authored markup reproduced from state, 1:1. Every// DOM read and write goes through df$.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();const accordionStates = ['default', 'all-open', 'all-closed'];// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each// described, and every config field typed, described and named in the code./** setState() configs per state - the accordion's states take none. */export interface AccordionStateConfigs { /** The items open as authored (each <details open>). */ default: {}; /** Every item open. */ 'all-open': {}; /** Every item closed. */ 'all-closed': {};}/** * The markup of a state - the one place a state becomes `open` attributes. * setState runs it on the live accordion, render() on a detached copy of the * authored markup. 'default' is the authored open set: restored from * `defaults` on the live element; already in place on an authored copy * (`defaults` null). */function applyMarkup(accordion, stateName, defaults) { dfDollar(accordion).find<HTMLDetailsElement>('.accordion-item').each((i, item) => { const open = stateName === 'all-open' ? true : stateName === 'all-closed' ? false : defaults ? defaults[i] : null; if (open !== null && open !== undefined) dfDollar(item).attr('open', open ? '' : null); });}/** * UI side of setState: syncs the DOM to a declared state. `_applying` suspends * the single-open/collapsible enforcement in the toggle listeners, otherwise * 'all-closed'/'all-open' would be undone by the enforcement. The `toggle` * event is queued (async), so the guard must outlive this function: it is * released on the next macrotask, after the queued toggle events have fired — * toggle-event tasks are queued synchronously by our `open` mutations, so they * always run before the timeout scheduled after them. The generation token * keeps back-to-back setState calls from releasing each other's guard. */function triggerStateChange(accordion, stateName, _config) { const gen = (accordion._applyGen ?? 0) + 1; accordion._applyGen = gen; accordion._applying = true; applyMarkup(accordion, stateName, accordion._defaultOpen ?? []); // toggle events queue as tasks AFTER our mutations, before this timeout task setTimeout(() => { if (accordion._applyGen === gen) accordion._applying = false; }, 0);}/** Registry-level API; pass the accordion element explicitly. Unknown names throw. */export const accordionApi = componentState({ component: 'accordion', states: accordionStates, apply: (accordion, state) => triggerStateChange(accordion, state.name, state.config), markup: (el, state) => applyMarkup(el, state.name, null),});df$.accordionApi = accordionApi;df$.accordionStates = accordionStates;function init() { // the State API binds to EVERY accordion - all-open/all-closed are generic // batch states independent of the single-open behavior below; data-api is // this loop's own marker so data-init stays the exclusive-toggle marker dfDollar('.accordion:not([data-api])').each((_i, accordion) => { dfDollar(accordion).data('api', ''); // snapshot the authored open set - that is the 'default' state to return to accordion._defaultOpen = dfDollar(accordion).find<HTMLDetailsElement>('.accordion-item').toArray().map((item) => item.open); // el.store + el.api (AGENTS.md "State through stores") bindComponent(accordion, accordionApi); }); dfDollar('.accordion[data-type="single"]:not([data-init])').each((_i, accordion) => { dfDollar(accordion).data('init', ''); const items = dfDollar(accordion).find<HTMLDetailsElement>('.accordion-item').toArray(); const collapsible = dfDollar(accordion).attr('data-collapsible') != null; items.forEach((item) => { // Cancellable pre-event: closing the LAST open item of a non-collapsible // single accordion is denied here, before the DOM changes. Reopening it // in the `toggle` handler instead would visibly flicker close→open now // that the ::details-content height transition animates. dfDollar(item).on('beforetoggle', (e) => { if (accordion._applying) return; // programmatic state change in progress if (e.newState !== 'closed' || collapsible) return; if (!items.some((i) => i !== item && i.open)) e.preventDefault(); }); dfDollar(item).on('toggle', () => { if (accordion._applying) return; // programmatic state change in progress if (item.open) { items.forEach((sibling) => { if (sibling !== item && sibling.open) sibling.open = false; }); } else if (!collapsible) { // fallback for browsers without beforetoggle (which the deny above // needs): reopen, accepting the flicker - better than losing the // single-open guarantee. Modern browsers never reach this branch // because a denied beforetoggle fires no toggle event at all. if (!items.some((i) => i.open)) item.open = true; } }); }); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub