Accordion
Disclosure · aria-expanded
Stacked sections that expand and collapse independently. The foundational aria-expanded disclosure pattern that a dozen other widgets build on.
Live example
Real and interactive — use it with the mouse, or Tab to it and use the keys below.
Keyboard
| Key | Action |
|---|---|
| Enter or Space | toggles the focused section open or closed |
| Tab / Shift + Tab | moves between section headers and into an open panel |
Screen reader
- Each header is announced as a button carrying its state — “collapsed” or “expanded”.
- Toggling re-announces the new state; the revealed panel is exposed as a region labelled by its header.
- Because the headers are real headings, heading navigation still lists every section.
ARIA notes
- The trigger is a native <button> inside a heading (h2–h4), so role, focus and Enter/Space come from the platform.
- aria-expanded on the button reflects the panel state; aria-controls links the button to its panel id.
- A collapsed panel is removed from the DOM entirely, so nothing hidden is focusable or read out.
Code
The real source of the example above — copy it and it works. This is the file that renders on this page, so the code and the live example can never drift apart.
import { useId, useState, type ReactNode } from 'react'
// Accessible accordion — the WAI-ARIA Disclosure pattern.
//
// Each header is a real <button> inside a heading, so Enter and Space toggle it
// for free (no manual key handling — reimplementing native button keys is the
// most common way to get this wrong). The button owns aria-expanded and points
// at its panel with aria-controls; the panel is removed from the DOM while
// collapsed, so nothing hidden is reachable by keyboard or screen reader.
//
// `allowMultiple` (default true) lets several panels stay open at once, which is
// the more forgiving default — a single-open accordion that snaps other panels
// shut can lose a reader's place.
export interface AccordionItem {
title: ReactNode
content: ReactNode
}
export function Accordion({
items,
allowMultiple = true,
headingLevel = 3,
}: {
items: AccordionItem[]
allowMultiple?: boolean
headingLevel?: 2 | 3 | 4
}) {
const baseId = useId()
const [open, setOpen] = useState<Set<number>>(() => new Set())
const Heading = `h${headingLevel}` as const
function toggle(i: number) {
setOpen((prev) => {
const next = allowMultiple ? new Set(prev) : new Set<number>()
if (prev.has(i)) next.delete(i)
else next.add(i)
return next
})
}
return (
<div className="divide-y divide-outline-variant rounded-xl border border-outline-variant">
{items.map((item, i) => {
const expanded = open.has(i)
const btnId = `${baseId}-h${i}`
const panelId = `${baseId}-p${i}`
return (
<div key={i}>
<Heading className="m-0">
<button
type="button"
id={btnId}
aria-expanded={expanded}
aria-controls={panelId}
onClick={() => toggle(i)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm font-semibold text-[color:var(--color-on-surface)] hover:bg-surface-container"
>
{item.title}
<Chevron expanded={expanded} />
</button>
</Heading>
{expanded && (
<div id={panelId} role="region" aria-labelledby={btnId} className="px-4 pb-4 text-sm text-on-surface-variant">
{item.content}
</div>
)}
</div>
)
})}
</div>
)
}
// Single stroke-based glyph (currentColor), matching the icon style used by the
// scan-stream indicators. The rotation is a transition, not a keyframe loop, and
// it is suppressed under prefers-reduced-motion so the open/closed state is
// still carried by the glyph's direction, never by motion alone.
function Chevron({ expanded }: { expanded: boolean }) {
return (
<svg
viewBox="0 0 12 12"
aria-hidden="true"
className="h-3 w-3 shrink-0 text-on-surface-variant transition-transform duration-150 motion-reduce:transition-none"
style={{ transform: expanded ? 'rotate(180deg)' : 'none' }}
fill="none"
stroke="currentColor"
strokeWidth="1.75"
>
<path d="M2.5 4.5 6 8l3.5-3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
Accessibility pitfalls
AvoidA <div> with onClick as the header.
DoUse a real <button> so keyboard, focus and role are free and correct.
AvoidThe collapsed panel is only visually hidden but stays in the tab order.
DoRemove it from the DOM (or use the hidden attribute) so it is not reachable.
AvoidNo aria-expanded, so a screen reader cannot tell open from closed.
DoToggle aria-expanded on the button on every state change.