Skip to content

Accordion

Progressive disclosure in place — one section open or many, and the content that must never be hidden inside one.

Also called Collapse, Disclosure, Expander, Spoiler, Show More, Details — in this system all of them are Accordion.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

A rollback re-points the load balancer at the previous healthy build. Nothing is rebuilt, so it completes in about eight seconds regardless of how large the application is.

Show more

The same disclosure pattern with one section and no heading. The fade is the affordance — a hard clip looks like a rendering bug rather than an invitation.

The health check failed in eu-west-2 at 14:32 after the connection pool saturated. The retry budget was exhausted before the circuit opened, so requests queued rather than failing fast. The rollback re-pointed the load balancer at build 4019 and completed in eight seconds. Error rates returned to baseline within a minute. The underlying cause was a migration that held a table lock for longer than the pool timeout, which we have since split into two smaller migrations.

Single open or multiple

Single-open closes what the user was reading without being asked. Reserve it for sections that are genuinely alternatives.

MultipleDefault

A rollback re-points the load balancer at the previous healthy build. Nothing is rebuilt, so it completes in about eight seconds regardless of how large the application is.

SingleOnly for alternatives

A rollback re-points the load balancer at the previous healthy build. Nothing is rebuilt, so it completes in about eight seconds regardless of how large the application is.

Accordion or tabs

Tabs keep exactly one section visible with no scrolling and no reflow. An accordion trades that for a scannable list of every heading at once.

AccordionAll headings visible

A rollback re-points the load balancer at the previous healthy build. Nothing is rebuilt, so it completes in about eight seconds regardless of how large the application is.

TabsOne panel, fixed height
OverviewLogsConfig

One panel at a time, no reflow.

What must never be inside one

A required field behind a collapsed heading produces a submit that fails for a reason the user cannot see. Expand any section containing an error, automatically.

Advanced settings1 error
A section holding an error opens itself and says so on the heading.

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Rollbacks
Collapsed
Rollbacks
Expanded
Rollbacks
Hover
Rollbacks
Focus
Regions24
With meta
Advanced1 error
With error
Unavailable
Disabled
Show more

Anatomy

Every part, every measurement, and the reason it is that number.

A rollback re-points the load balancer at the previous healthy build. Nothing is rebuilt, so it completes in about eight seconds regardless of how large the application is.

Three sections, each a button inside a heading, with the panel indented to the title’s left edge rather than the chevron’s.

  1. Trigger height52px (44px small)

    Comfortably above the touch minimum, because the whole row is the target and it is pressed repeatedly while scanning.

  2. Chevron15px, leading, rotates 180°

    Leading rather than trailing: it stays in one column as titles wrap, and it is already where the eye lands when scanning a list of headings.

  3. Title13px, 500 when open

    The weight change is the second signal alongside the chevron. It must not change the row height, so the font must have a real medium weight.

  4. Panel indentAligned to the title

    The body starts at the title’s left edge, not the chevron’s. That alignment is what makes the panel read as belonging to its heading.

  5. Divider1px between sections

    Between items only, never above the first or below the last inside a bordered container — doubled lines look like a rendering fault.

  6. Panel padding0 16px 16px

    No top padding: the trigger’s own bottom padding already provides it. Adding both leaves a gap that reads as a missing element.

  7. Transition180ms, height + opacity

    Short. Anything longer than about 200ms makes an accordion feel sluggish when the user is opening several in a row.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-surface—Container background
--ds-border-subtle—Container edge and dividers
--ds-layer-hover—Trigger hover
--ds-fg—Section titles
--ds-fg-secondary—Panel body
--ds-fg-muted—Chevron
--ds-danger-text—A section containing an error
--ds-focus-ring—Focus outline, inset

Spacing

TokenValueUsed for
--space-4Trigger and panel horizontal padding

Radius

TokenValueUsed for
--radius-lgContainer corners

Motion

TokenValueUsed for
--duration-normalExpand and chevron rotation

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingLabel gapTypeMax widthWhen to use
Small44px trigger10px 12px—12px—Inside a card or a sidebar, where the accordion is secondary to the surrounding content.
Medium52px trigger14px 16px—13px—The default. FAQ pages and settings sections.
Panel—0 16px 16px———No top padding — the trigger already provides it.
Indent——Title left edge——The panel aligns to the title, not the container edge.
Measure————40remAbout 70 characters. An accordion stretched across a wide page produces unreadable lines when opened.

Do

<h3><button aria-expanded="true"
  aria-controls="panel-1">…</button></h3>
Put a button inside a headingThe heading places the section in the document outline so it can be jumped to; the button carries aria-expanded. Neither substitutes for the other.

A rollback re-points the load balancer at the previous healthy build. Nothing is rebuilt, so it completes in about eight seconds regardless of how large the application is.

Allow several sections open at onceSingle-open closes what the user was reading without being asked. Reserve it for sections that are genuinely alternatives.
Advanced1 error
Open any section that contains an errorA validation failure inside a collapsed section is a submit that fails for a reason the user cannot see. Expand it and mark the heading.
Entire row is the button
Make the whole heading row the targetA chevron-only target is a 15px hit area on a 52px row. Users click the title because it looks like the thing to click.

Don't

Billing details *
Do not hide required fieldsThe user submits, the form fails, and the reason is behind a heading they never opened. This is the single most damaging use of the component.
Regioneu-west-2
Do not collapse two sentencesA click to reveal one short paragraph costs more than showing it. The accordion is for content long enough that the heading list is worth having.
Settings Advanced Experimental
Do not nest accordionsTwo levels of disclosure means the user cannot tell what is hidden or how deep it goes, and the indentation eats the content width.
transition: height 500ms ease-in-out
Do not animate for longer than about 200msA user opening four sections in a row waits for four animations. What reads as smooth once reads as slow the fourth time.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.6Headings and LabelsAA2.5.8Target Size (Minimum)AA4.1.2Name, Role, ValueA

Contrast

  • The chevron owes 3:1 — it is the affordance, and aria-expanded alone does not help a sighted user.
  • The expanded state changes both the chevron rotation and the title weight, so it does not depend on either one alone.
  • Dividers owe 3:1 if they are the only thing separating sections; if each section has its own surface, they are decorative.
  • A section marked as containing an error must carry a badge as well as a colour.

Keyboard

TabMoves to each trigger, then into an open panel’s own controls.
Enter / SpaceToggles the focused section.
↑ / ↓Optional: moves between triggers, skipping panel content. Valuable in a long FAQ, and it must not be the only way.
Home / EndOptional: jumps to the first or last trigger.

Screen readers

  • Announces as "How do rollbacks work?, button, collapsed" then "expanded".
  • Heading levels are how a screen-reader user navigates an accordion — they jump by heading, not by tabbing. Getting the level wrong breaks that entirely.
  • A collapsed panel must be genuinely hidden. Height zero with overflow hidden leaves the content readable by assistive tech while invisible on screen.

Focus & touch

  • Focus stays on the trigger when a section opens, so the user can continue down the list. It must never jump into the panel — that would strand a keyboard user who was only scanning headings. Closing a section that contains the focused element moves focus back to its trigger.
  • The whole heading row is the target, which comfortably clears 44px at the default size. Accordions work well on mobile precisely because they trade horizontal space for vertical, but keep the section count low: a phone screen showing eight collapsed headings and no content looks like a page that failed to load.
AttributeApplied toNotes
<h2>–<h4> wrapping a buttonThe triggerThe heading level must match the surrounding outline. A button that is not in a heading cannot be jumped to.
aria-expandedThe trigger buttonThe state. It must track the real value — a hardcoded false is a silent bug.
aria-controlsThe trigger buttonPoints at the panel id, associating the two.
role="region"The panelWith aria-labelledby pointing back at the trigger. Only worth it for a handful of sections — twenty regions clutters the landmark list.
hiddenA closed panelOr display:none. A visually collapsed panel that is still in the accessibility tree is announced as available content that cannot be seen.

Code

Example usage

tsx
1import { Accordion, AccordionItem } from '@/ui/Surface'23<Accordion multiple defaultOpen={['rollback']}>4  {faqs.map((f) => (5    <AccordionItem key={f.id} id={f.id} title={f.title} meta={f.category}>6      {f.body}7    </AccordionItem>8  ))}9</Accordion>1011// A collapsed section holding a validation error is a submit that fails for12// a reason the user cannot see. Open it and mark the heading.13React.useEffect(() => {14  const withErrors = sections15    .filter((s) => s.fields.some((f) => errors[f]))16    .map((s) => s.id)17  if (withErrors.length) setOpen((o) => [...new Set([...o, ...withErrors])])18}, [errors])1920// Native <details> is a legitimate implementation and gets the semantics,21// the keyboard and the print behaviour for free.22<details>23  <summary>How do rollbacks work?</summary>24  <p>A rollback re-points the load balancer at the previous healthy build.</p>25</details>2627// Animating height needs a measured value; 'auto' does not transition.28const [height, setHeight] = React.useState(0)29React.useLayoutEffect(() => {30  setHeight(open ? panelRef.current!.scrollHeight : 0)31}, [open, children])

Framework-free HTML

html
<div class="ds-accordion">
  <!-- The heading gives it a place in the outline; the button gives it
       state. Both are required. -->
  <h3>
    <button
      type="button"
      id="trigger-rollback"
      aria-expanded="true"
      aria-controls="panel-rollback"
    >
      <svg aria-hidden="true">…</svg>
      How do rollbacks work?
    </button>
  </h3>

  <div id="panel-rollback" role="region" aria-labelledby="trigger-rollback">
    <p>A rollback re-points the load balancer at the previous healthy build.</p>
  </div>

  <h3>
    <button type="button" id="trigger-regions"
            aria-expanded="false" aria-controls="panel-regions">
      <svg aria-hidden="true">…</svg>
      Which regions can I deploy to?
    </button>
  </h3>

  <!-- hidden, not height:0 — a collapsed panel must be gone from the
       accessibility tree, not merely invisible. -->
  <div id="panel-regions" role="region" aria-labelledby="trigger-regions" hidden>
    <p>Twenty-four regions across four continents.</p>
  </div>
</div>

CSS

css
.ds-accordion {
  border: 1px solid var(--ds-border-subtle);
  border-radius: var(--radius-lg);
  overflow: hidden;
}

/* Between items only. A rule above the first or below the last doubles the
   container border and reads as a rendering fault. */
.ds-accordion > * + * {
  border-block-start: 1px solid var(--ds-border-subtle);
}

.ds-accordion button {
  display: flex;
  align-items: center;
  gap: 12px;
  inline-size: 100%;
  /* The whole row, not the chevron: a 15px target on a 52px row is a miss
     waiting to happen. */
  padding: 14px 16px;
  text-align: start;
}

.ds-accordion button:hover { background: var(--ds-layer-hover); }

/* Leading, so it stays in one column as titles wrap. */
.ds-accordion svg {
  flex: 0 0 auto;
  transition: rotate 180ms cubic-bezier(0.2, 0, 0, 1);
}
.ds-accordion button[aria-expanded='true'] svg { rotate: 180deg; }

/* Panel aligns to the TITLE's left edge, not the container's — that is what
   makes it read as belonging to its heading. */
.ds-accordion [role='region'] {
  padding: 0 16px 16px calc(16px + 15px + 12px);
}

/* Modern height animation with no JS measurement. */
@supports (interpolate-size: allow-keywords) {
  .ds-accordion [role='region'] {
    interpolate-size: allow-keywords;
    transition: height 180ms, content-visibility 180ms allow-discrete;
  }
}

@media (prefers-reduced-motion: reduce) {
  .ds-accordion svg,
  .ds-accordion [role='region'] { transition: none; }
}

Component API

Accordion

PropTypeDefaultDescription
multiplebooleanfalseAllows several sections open at once. Prefer true — single-open closes what the user was reading.
defaultOpenstring[]—Ids open on mount. Open the section most people need rather than starting fully collapsed.
size'sm' | 'md''md'Trigger height and type size.
headingLevel2 | 3 | 43Must match the surrounding document outline — this is how screen-reader users navigate.

AccordionItem

PropTypeDefaultDescription
id*string—Stable. Used for open state and for the aria-controls relationship.
title*ReactNode—Written as a question or a noun phrase — it is the navigation for the whole component.
metaReactNode—A badge or count at the trailing edge, so the collapsed heading still says something about what is inside.
disabledbooleanfalseA section that cannot be opened yet. Say why on the heading.

Notes

Professional tips

  • Write headings as questions or specific noun phrases. "Rollbacks" is scannable; "More information" is a heading that tells the user nothing about whether to open it.
  • Put a count or a summary badge on the collapsed heading. "Regions (24)" gives the user a reason to open it or not.
  • Open the section most people need by default. A fully collapsed page makes every user pay a click for the common case.
  • Deep-link to a section with a URL fragment and open it on load. FAQ links that land on a collapsed heading are a support answer that does not answer.
  • Native <details> is a perfectly good implementation. It brings the semantics, the keyboard model, and printing with sections expanded, for free.

Performance

  • Animating height requires a measured pixel value — "auto" does not transition. Modern CSS has interpolate-size: allow-keywords, which removes the JavaScript entirely.
  • Render panel content lazily for heavy sections, but keep the heading and its state present so the outline is complete before anything loads.
  • Do not animate more than a couple of sections at once. Opening all with one control should be instant, not a cascade.
  • Use content-visibility: auto on long collapsed panels to skip their layout until they are opened.

Common mistakes

  • A clickable heading with no button inside, so aria-expanded has nowhere to live.
  • Wrong heading levels, breaking the jump-by-heading navigation screen-reader users rely on.
  • A collapsed panel at height zero rather than hidden, leaving invisible content in the accessibility tree.
  • Required fields or validation errors hidden inside a collapsed section.
  • A chevron-only click target on a full-width row.
  • Single-open by default, closing the content the user was reading.
  • Animations over 200ms, which compound when opening several sections.
  • Nested accordions, where nobody can tell how deep the content goes.

Real-world recommendations

  • FAQ accordions work because the questions are the interface. If your headings are not the thing users are scanning for, an accordion is the wrong container.
  • In settings, "Advanced" sections are the classic legitimate use — but audit what is inside periodically. Features hidden there stay unused, and that is sometimes a finding rather than a design.
  • Collapsed content is invisible to on-page search in most browsers. If users search your page with ⌘F, expand-all is worth having.
  • Mobile is where accordions earn the most: they trade horizontal space, which a phone does not have, for vertical scrolling, which it does.