Theme
Design your own
On this page (15)
Component Skill — components/accordion/component-skill.md

Native basis

<details> / <summary> elements. The browser provides:

Web Platform APIs

<details><summary>::details-contenttoggle event@starting-style

Classes

.accordion.accordion-item.accordion-trigger.accordion-chevron.accordion-content

Data attributes

• data-type

• data-collapsible

Keyboard

KeyBehaviorTabMove focus between summary elementsEnterToggle the focused itemSpaceToggle the focused item

Notes

• <details>/<summary> is the most accessible accordion implementation - it works with zero JS and zero ARIA

• For single-open behavior, the toggle event on <details> fires after the state changes

• The open attribute is the source of truth for whether an item is expanded

• Avoid nesting accordions - use a flat list with clear headings instead

• A non-collapsible single accordion never closes its last open item - the attempt is denied in the cancellable beforetoggle event (preventDefault()), so the click is a silent no-op; reopening in toggle would flicker

• The chevron rotation relies on details[open] > selector - this is pure CSS

• Content height animation uses ::details-content pseudo-element with block-size transition and @starting-style for the enter animation - fully CSS-only, no JS measurement needed

• Set data-type="single" for accordion behavior (only one open); omit for disclosure list (any number open)

• Set data-collapsible alongside data-type="single" to allow all items to be closed

§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 initializes
  • all-open - every <details> item expanded (single-open enforcement suspended)
  • all-closed - every item collapsed, even without data-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:

StateTypeValuesDefaultDescription
all-openbooleantrue, falsefalseEvery .accordion-item opened in one batch.
all-closedbooleantrue, falsefalseEvery .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.

StateDescription
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

MemberDescription
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.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?AccordionStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

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 { name: AccordionState; config: AccordionStateConfigs[AccordionState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state?{ name: AccordionState; config: AccordionStateConfigs[AccordionState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

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

MemberDescription
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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?AccordionStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

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.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: AccordionState; config: AccordionStateConfigs[AccordionState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state{ name: AccordionState; config: AccordionStateConfigs[AccordionState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.accordionApi.store(el: HTMLElement): Store<{ name: AccordionState; config: AccordionStateConfigs[AccordionState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: AccordionState; config: AccordionStateConfigs[AccordionState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?AccordionStateConfigs[S]its config
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