Skip to content

Component

accordion

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and accordion.css. No scripts.

Source

Copy both files, or run npx nojsui add accordion.

Source for accordion
<!-- .sk-accordion — disclosure panels built on <details> and <summary>.

     data-exclusive on the wrapper is documentation, not behaviour: what makes
     the group exclusive is the shared name="" on each <details>, which the
     browser enforces. Drop the name and the panels open independently. -->
<div class="sk-accordion" data-exclusive>
  <details class="sk-accordion__item" name="sk-accordion-demo" open>
    <summary class="sk-accordion__summary">Why zero JavaScript?</summary>
    <div class="sk-accordion__panel">
      <p class="sk-accordion__text">
        Less to download, parse and break. The browser already knows how to do
        this — a disclosure is one element and one attribute.
      </p>
    </div>
  </details>

  <details class="sk-accordion__item" name="sk-accordion-demo">
    <summary class="sk-accordion__summary">Is it accessible?</summary>
    <div class="sk-accordion__panel">
      <p class="sk-accordion__text">
        The keyboard and screen-reader behaviour come from the browser rather
        than a reimplementation, so there is nothing here to get wrong.
      </p>
    </div>
  </details>

  <details class="sk-accordion__item" name="sk-accordion-demo">
    <summary class="sk-accordion__summary">What about older browsers?</summary>
    <div class="sk-accordion__panel">
      <p class="sk-accordion__text">
        Every panel stays reachable all the way down to plain HTML. Only the
        height animation and the exclusive grouping need recent engines.
      </p>
    </div>
  </details>
</div>
/* .sk-accordion — disclosure panels on <details> / <summary>.

   Open and closed state, the toggle, keyboard operation and the accessibility
   tree are all the element's own. This file styles the marker, the panel, and —
   where the engine can — animates the height.

   Specs:
   - <details name> ......... https://html.spec.whatwg.org/multipage/interactive-elements.html#attr-details-name
   - ::details-content ...... https://drafts.csswg.org/css-pseudo-4/#details-content-pseudo
   - interpolate-size ....... https://drafts.csswg.org/css-values-5/#interpolate-size */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins. */
.sk-accordion {
  --_bg: var(--sk-accordion-bg, var(--sk-color-surface));
  --_border: var(--sk-accordion-border-color, var(--sk-color-border));
  --_radius: var(--sk-accordion-radius, var(--sk-radius-lg));
  --_summary-fg: var(--sk-accordion-summary-fg, var(--sk-color-text));
  --_summary-bg-hover: var(--sk-accordion-summary-bg-hover, var(--sk-color-surface-raised));
  --_marker: var(--sk-accordion-marker-color, var(--sk-color-text-muted));
  --_text-fg: var(--sk-accordion-text-fg, var(--sk-color-text-muted));

  border: var(--sk-border-width) solid var(--_border);
  border-radius: var(--_radius);
  background: var(--_bg);
  overflow: hidden;
}

.sk-accordion__item + .sk-accordion__item {
  border-block-start: var(--sk-border-width) solid var(--_border);
}

.sk-accordion__summary {
  display: flex;
  align-items: center;
  gap: var(--sk-space-sm);
  padding-block: var(--sk-space-sm);
  padding-inline: var(--sk-space-md);
  color: var(--_summary-fg);
  font-weight: var(--sk-weight-medium);
  cursor: pointer;
  min-block-size: var(--sk-size-tap-target);

  /* The UA triangle is replaced by the chevron below. `display: flex` already
     removes it in most engines; ::marker covers the rest without needing
     ::-webkit-details-marker. */
  list-style: none;
}

.sk-accordion__summary::marker,
.sk-accordion__summary::-webkit-details-marker {
  display: none;
  content: "";
}

.sk-accordion__summary:hover {
  background: var(--_summary-bg-hover);
}

/* A chevron drawn with borders — no icon font, no SVG, nothing to load. */
.sk-accordion__summary::before {
  content: "";
  inline-size: 0.5em;
  block-size: 0.5em;
  border-inline-end: 2px solid var(--_marker);
  border-block-end: 2px solid var(--_marker);
  rotate: -45deg;
  translate: 0 -0.125em;
}

/* Rotating to point down when open. In RTL the chevron starts pointing the
   other way, which is why the closed state uses a logical -45deg from an
   inline-end/block-end corner rather than a hardcoded direction. */
[open] > .sk-accordion__summary::before {
  rotate: 45deg;
  translate: 0 -0.125em;
}

@media (prefers-reduced-motion: no-preference) {
  .sk-accordion__summary::before {
    transition: rotate var(--sk-motion-fast) var(--sk-ease-out);
  }
}

.sk-accordion__panel {
  padding-block: 0 var(--sk-space-md);
  padding-inline: var(--sk-space-md);
}

.sk-accordion__text {
  margin: 0;
  color: var(--_text-fg);
  font-size: var(--sk-text-sm);
  max-inline-size: var(--sk-size-measure);
}

/* ---------------------------------------------------------------------------
   Height animation

   Entirely inside @supports. Without interpolate-size there is no way to
   transition to `auto`, so engines that lack it get an instant open and close —
   the panel content is never hidden or delayed, it simply appears. That is the
   fallback the whole block degrades to, and nothing above depends on it.
--------------------------------------------------------------------------- */

@supports (interpolate-size: allow-keywords) {
  @media (prefers-reduced-motion: no-preference) {
    .sk-accordion {
      interpolate-size: allow-keywords;
    }

    .sk-accordion__item::details-content {
      block-size: 0;
      overflow: hidden;
      transition:
        block-size var(--sk-motion-base) var(--sk-ease-out),
        content-visibility var(--sk-motion-base) allow-discrete;
    }

    .sk-accordion__item[open]::details-content {
      block-size: auto;
    }
  }
}

Browser support

Baseline newly available

Shipped in every major engine, but only recently — older versions need the fallback.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
<details>widely127949618496
Mutually exclusive <details> elementsnewly12012013017.212013017.2
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
::details-contentnewly13113114318.413114318.4
interpolate-sizelimited129129129

Usage

<div class="sk-accordion" data-exclusive>
  <details class="sk-accordion__item" name="faq" open>
    <summary class="sk-accordion__summary">Why zero JavaScript?</summary>
    <div class="sk-accordion__panel">
      <p class="sk-accordion__text">…</p>
    </div>
  </details>

  <details class="sk-accordion__item" name="faq">…</details>
</div>

Two variants, one attribute. Give every <details> the same name and the group is exclusive — opening one closes the others, enforced by the browser. Remove the name and the panels open independently. Nothing else changes.

data-exclusive on the wrapper is documentation for whoever reads the markup; it has no behaviour attached. The name is what does the work.

How it works

There is no accordion here, only <details>. Open and closed state, the toggle, Enter/Space, the disclosure semantics and the accessibility tree are all the element’s own. This component contributes a marker, a panel and — where the engine can — a height animation.

Verified in Chromium, WebKit and Gecko: exclusive grouping closes the sibling, Enter on a <summary> toggles, and a closed panel’s content is absent from the accessibility tree rather than merely invisible.

The chevron is drawn with borders. Two edges of a square, rotated — no icon font, no SVG, nothing to load, and it inherits currentColor so it follows the theme for free. The UA triangle is removed with list-style: none plus a ::marker reset, verified gone in all three engines.

The height animation is the only enhanced part

Animating to auto requires interpolate-size: allow-keywords, which is Chromium-only today. So the whole animation block sits inside @supports, and engines without it open and close instantly.

That fallback is not a degraded experience so much as a plainer one: the panel content is never hidden, delayed or clipped — it simply appears. Nothing outside the @supports block depends on anything inside it.

The animation also sits inside a prefers-reduced-motion: no-preference guard, so a reduced-motion user gets the same instant open as an engine without interpolate-size.

Theming

Set any of these anywhere above the component — :root, a section wrapper, or one instance. accordion.css only ever reads them, so the nearest declaration wins (ADR 0011).

PropertyDefaultControls
--sk-accordion-bg--sk-color-surfaceAccordion background
--sk-accordion-border-color--sk-color-borderOuter border and the rule between items
--sk-accordion-radius--sk-radius-lgCorner radius
--sk-accordion-summary-fg--sk-color-textSummary text
--sk-accordion-summary-bg-hover--sk-color-surface-raisedSummary background on hover
--sk-accordion-marker-color--sk-color-text-mutedThe chevron
--sk-accordion-text-fg--sk-color-text-mutedPanel prose
:root { --sk-accordion-radius: 0; }

Whether opening one item closes the others is the name attribute on <details>, not a knob — it is the browser’s behaviour, and the whole reason this component needs no script.

Keyboard contract

Entirely native.

KeyBehaviour
TabMoves to each <summary> in document order
Enter / SpaceToggles that panel

Note that a disclosure group is not the ARIA tablist pattern: there are no arrow-key semantics, and Tab visits every summary. That is correct for <details> and is what a screen reader will describe.

Verified manually in VoiceOver and NVDA: not yet — do this before the component is marked done.

Accessibility notes

  • Closed panels are genuinely closed. Their content is out of the accessibility tree, so a screen-reader user is not walked through collapsed prose.
  • The summary is the button. Do not nest an interactive control inside <summary> — it becomes a click target inside a click target.
  • Exclusive mode can close what a user is reading. If several panels are likely to be wanted at once, leave the name off and let them open independently.

Degradation

Baseline column from support.json — regenerate with pnpm support.

Feature usedBaseline statusBehavior without it
<details> / <summary>widely
name= groupingnewlyPanels open independently instead of exclusively. Everything still works; the group is just not mutually exclusive.
::details-contentnewlyNo styling hook for the content wrapper; the panel’s own padding still applies.
interpolate-sizelimitedInstant open and close instead of an animated height. Content is never hidden or delayed.

Every degradation here is cosmetic. The component’s function — disclose and hide content, by keyboard or pointer — comes from <details>, which is Baseline widely available and has been for years.