Skip to content

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

KeyAction
Enter or Spacetoggles the focused section open or closed
Tab / Shift + Tabmoves 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.

src/components/library/Accordion.tsx
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.