AccordionATM
Collapsible content sections built on native <details> / <summary>. Multi-open needs zero JS. Single-open adds a small toggle handler.
On this page (14)
§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.
§Bold headings
data-size scales every heading: sm, md (default), lg semibold, xl bold.
§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. |
§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").// 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 } from '../../shared/state-api.js';const df$ = defussGlobals();const accordionStates = ['default', 'all-open', 'all-closed'];/** * 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 items = Array.from(accordion.querySelectorAll('.accordion-item')); const gen = (accordion._applyGen ?? 0) + 1; accordion._applyGen = gen; accordion._applying = true; switch (stateName) { case 'default': items.forEach((item, i) => { item.open = (accordion._defaultOpen ?? [])[i] ?? item.open; }); break; case 'all-open': items.forEach((item) => { item.open = true; }); break; case 'all-closed': items.forEach((item) => { item.open = false; }); break; } // 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 = { setState(accordion, stateName, config = {}) { if (!accordionStates.includes(stateName)) { throw new Error(`accordion: unknown state "${stateName}" (supported: ${accordionStates.join(', ')})`); } triggerStateChange(accordion, stateName, config); // state lives on the ELEMENT, not the module: 26 components share one page, // and each instance may sit in a different state accordion.dataset.stateName = stateName; accordion._stateConfig = config; }, getState(accordion) { return { name: accordion.dataset.stateName || 'default', config: accordion._stateConfig ?? {} }; },};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 document.querySelectorAll('.accordion:not([data-api])').forEach((accordion) => { accordion.dataset.api = ''; const items = accordion.querySelectorAll('.accordion-item'); // snapshot the authored markup - that is the 'default' state to return to accordion._defaultOpen = Array.from(items).map((item) => item.open); // bind-scope the api per instance: `$('#x').api.setState('all-open')` accordion.api = { setState: (stateName, config) => accordionApi.setState(accordion, stateName, config), getState: () => accordionApi.getState(accordion), }; }); document.querySelectorAll('.accordion[data-type="single"]:not([data-init])').forEach((accordion) => { accordion.dataset.init = ''; const items = accordion.querySelectorAll('.accordion-item'); const collapsible = accordion.hasAttribute('data-collapsible'); 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. item.addEventListener('beforetoggle', (e) => { if (accordion._applying) return; // programmatic state change in progress if (e.newState !== 'closed' || collapsible) return; if (!Array.from(items).some((i) => i !== item && i.open)) e.preventDefault(); }); item.addEventListener('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. const anyOpen = Array.from(items).some((i) => i.open); if (!anyOpen) item.open = true; } }); }); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub