Theme
On this page (14)
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.

§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