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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
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.
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.
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.
Every part, every measurement, and the reason it is that number.
Three sections, each a button inside a heading, with the panel indented to the title’s left edge rather than the chevron’s.
- Trigger height52px (44px small)
Comfortably above the touch minimum, because the whole row is the target and it is pressed repeatedly while scanning.
- 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.
- 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.
- 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.
- 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.
- 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.
- Transition180ms, height + opacity
Short. Anything longer than about 200ms makes an accordion feel sluggish when the user is opening several in a row.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| --space-4 | Trigger and panel horizontal padding |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Container corners |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-normal | Expand and chevron rotation |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Label gap | Type | Max width | When to use |
|---|---|---|---|---|---|---|
| Small | 44px trigger | 10px 12px | — | 12px | — | Inside a card or a sidebar, where the accordion is secondary to the surrounding content. |
| Medium | 52px trigger | 14px 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 | — | — | — | — | 40rem | About 70 characters. An accordion stretched across a wide page produces unreadable lines when opened. |
<h3><button aria-expanded="true"
aria-controls="panel-1">…</button></h3>Not a checklist to run at the end. These are the requirements the component was built from.
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
| Tab | Moves to each trigger, then into an open panel’s own controls. |
| Enter / Space | Toggles the focused section. |
| ↑ / ↓ | Optional: moves between triggers, skipping panel content. Valuable in a long FAQ, and it must not be the only way. |
| Home / End | Optional: 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.
| Attribute | Applied to | Notes |
|---|---|---|
| <h2>–<h4> wrapping a button | The trigger | The heading level must match the surrounding outline. A button that is not in a heading cannot be jumped to. |
| aria-expanded | The trigger button | The state. It must track the real value — a hardcoded false is a silent bug. |
| aria-controls | The trigger button | Points at the panel id, associating the two. |
| role="region" | The panel | With aria-labelledby pointing back at the trigger. Only worth it for a handful of sections — twenty regions clutters the landmark list. |
| hidden | A closed panel | Or display:none. A visually collapsed panel that is still in the accessibility tree is announced as available content that cannot be seen. |
Example usage
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
<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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| multiple | boolean | false | Allows several sections open at once. Prefer true — single-open closes what the user was reading. |
| defaultOpen | string[] | — | Ids open on mount. Open the section most people need rather than starting fully collapsed. |
| size | 'sm' | 'md' | 'md' | Trigger height and type size. |
| headingLevel | 2 | 3 | 4 | 3 | Must match the surrounding document outline — this is how screen-reader users navigate. |
AccordionItem
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| meta | ReactNode | — | A badge or count at the trailing edge, so the collapsed heading still says something about what is inside. |
| disabled | boolean | false | A section that cannot be opened yet. Say why on the heading. |
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.