Designer view

Accordion

A vertically-stacked set of disclosure controls, each pairing a header button with a panel of content that expands or collapses. Distinct from Tabs (always shows one panel, replaces on selection) by allowing zero, one, or multiple panels open at once and by preserving all panel labels on screen. Used for FAQs, settings groupings, progressive disclosure of dense reference content, and any list of named regions where content is browsable but not all needed simultaneously.

Also called Expandable list

When to use

Use

For a list of named regions where content is browsable but not all needed simultaneously — FAQs, settings groupings, progressive disclosure of dense reference content. The user can scan all headers, expand the items they need, and collapse the ones they don't. Accordion preserves all panel labels on screen.

Avoid

For mutually-exclusive parallel views of the same subject (settings tabs, alternative presentations of the same data) — that is `Tabs`. For a single collapsible region not part of a list — that is `Disclosure`. For navigation between independent pages — that is `SidebarNav`. For sequential flows that must complete in order — that is `Stepper`.

Versus related

  • tabs

    `Tabs` always shows one panel and replaces it on selection; `Accordion` allows zero, one, or multiple panels open at once. Tabs are lateral (peer content chunks at the same level); Accordion is hierarchical (each header is a heading in the document outline). Vertical Tabs and single-mode Accordion can look similar — the distinguisher is whether all labels stay visible (Accordion) or only the selected label is emphasised (Tabs).

  • disclosure

    `Disclosure` is a single collapsible region with one toggle; `Accordion` is a list of disclosures grouped as a unit with shared keyboard navigation (ArrowDown / Up between items). A standalone collapsible region is Disclosure, not Accordion-of-one-item.

  • sidebar-nav

    `SidebarNav` navigates between independent pages with their own URLs; `Accordion` discloses inline content within a single page. A nav-style accordion (clicking an item navigates) is a SidebarNav with collapsible sections, not an Accordion.

  • stepper

    `Stepper` enforces a sequential flow with state carried between steps; `Accordion` is a parallel list of independent panels, each toggleable in any order. A wizard-style "expand each step in turn" UI may *look* like an accordion but is canonically a Stepper — the ordering and validation contract differs.

Accordion groups multiple disclosure regions under shared keyboard navigation. Each row pairs a heading-wrapped trigger with a labeled panel; the canonical single-expansion mode keeps at most one panel open at a time, while multi-mode allows parallel expansion. Most production drift centres on the heading wrapper and on aria-expanded as the source of truth — both routinely shipped wrong. The reference below documents the slot anatomy, the decision graph between expansion modes, the keyboard contract, and the cross-framework expression.

Highlight
Fig 1.1 · Accordion · Designer view

Implementations

How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.

cdk MatAccordion / MatExpansionPanel (from @angular/material/expansion)
app.component.ts
import { Component, signal } from '@angular/core';
import { MatExpansionModule } from '@angular/material/expansion';
@Component({
selector: 'app-demo',
standalone: true,
imports: [MatExpansionModule],
template: `
<mat-accordion
[multi]="false"
displayMode="default"
togglePosition="after"
[hideToggle]="false"
>
<mat-expansion-panel
[expanded]="openIndex() === 0"
(opened)="openIndex.set(0)"
(closed)="onClose(0)"
>
<mat-expansion-panel-header
collapsedHeight="48px"
expandedHeight="64px"
>
<mat-panel-title>Shipping policy</mat-panel-title>
<mat-panel-description>Free over $50</mat-panel-description>
</mat-expansion-panel-header>
<p>We ship within 2 business days.</p>
</mat-expansion-panel>
<mat-expansion-panel
[expanded]="openIndex() === 1"
(opened)="openIndex.set(1)"
>
<mat-expansion-panel-header>
<mat-panel-title>Returns</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<!-- lazy-mounted on first expand -->
<p>Returns accepted within 30 days.</p>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
`,
})
export class AppDemoComponent {
openIndex = signal<number | null>(0);
onClose(idx: number): void {
if (this.openIndex() === idx) this.openIndex.set(null);
}
}

Divergence

From Type → To Rationale
anatomy[header] reshaped No heading element wraps the header. MatExpansionPanelHeader renders as a custom element with `role="button"` on the host — not as a <button> inside a heading. MatExpansionPanelTitle and MatExpansionPanelDescription are content-projection directives inside the header, not a semantic heading wrapper. There is no input for heading level (h2–h6). APG requires each accordion trigger to sit inside a heading element so SR users can navigate by heading. Material ships a self-contained mat-expansion-panel-header that uses role="button" directly on the host element and omits the heading wrapper entirely. The trade-off is simpler CSS layout control (expandedHeight / collapsedHeight inputs) at the cost of APG heading-navigation conformance. Consumers who need heading semantics must wrap mat-expansion-panel-header in a heading element themselves. Source: github.com/angular/components blob src/material/expansion/expansion-panel-header.ts (fetched 2026-05-04).
anatomy[trigger] reshaped MatExpansionPanelHeader is the trigger — a single custom element with `role="button"`, `aria-expanded`, and `aria-controls` wired. It is not a native <button>; tabIndex defaults to 0 and is removed to -1 on disabled via host binding. MatExpansionPanelTitle and MatExpansionPanelDescription are slotted via content projection inside the same header element. Canonical trigger is a real <button> inside the heading. Material collapses trigger + title + description into one composed host element (mat-expansion-panel-header) using role="button" rather than a native button, keeping layout control simpler. aria-expanded and aria-controls are wired correctly, so SR state announcements work, but the native-button constraint is not met. Source: github.com/angular/components blob src/material/expansion/expansion-panel-header.ts (fetched 2026-05-04).
anatomy[icon] renamed Built-in toggle indicator rendered internally by MatExpansionPanelHeader; controlled by `hideToggle: boolean` on MatExpansionPanel or MatAccordion, and `togglePosition: 'before' | 'after'` on MatAccordion (default 'after'). Consumers can project a custom indicator via the `mat-expansion-panel-header` content or override via CSS. Canonical icon is an optional, author-controlled slot. Material bakes the chevron-style toggle into MatExpansionPanelHeader with show/hide and position as inputs, rather than exposing a composable slot. hideToggle on the panel or accordion suppresses the built-in indicator; there is no first-class slot for a replacement icon. Source: github.com/angular/components blob src/material/expansion/accordion.ts (fetched 2026-05-04).
axes.variants reshaped `displayMode: 'default' | 'flat'` on MatAccordion. 'default' adds spacing around expanded panels; 'flat' removes the inter-panel gaps. There is no 'bordered' or 'flush' variant matching canonical taxonomy. Canonical variants are bordered / contained / flush. Material provides only a spacing-mode axis (default vs flat) mapped through the MatAccordionDisplayMode type. Visual containment, borders, and flush treatment are controlled entirely by the Material Design theme and CSS — not by a first-class variant input. Source: github.com/angular/components blob src/material/expansion/accordion.ts (fetched 2026-05-04).
axes.properties[collapsible] omitted Canonical `collapsible` boolean controls whether the currently-open item in single-open mode can be collapsed to a fully-closed state. MatAccordion has no equivalent input. In single-open mode (multi=false) the user can always click an open panel to close it — the "no-close-if-last-open" pattern is not offered. Source: github.com/angular/components blob src/material/expansion/accordion.ts (fetched 2026-05-04).
axes.properties[density] omitted Canonical `density: comfortable | compact` property. MatAccordion and MatExpansionPanel have no density input. Angular Material density is applied globally via the SCSS mixin `mat.expansion-panel-density($scale)`; per-instance density control is absent. Source: github.com/angular/components blob src/material/expansion/accordion.ts (fetched 2026-05-04).
events[expandedChange] reshaped CdkAccordionItem (base of MatExpansionPanel) emits three outputs: `opened` (void, fires on expand), `closed` (void, fires on collapse), and `expandedChange: EventEmitter<boolean>` (fires on both with the new boolean state). There is no accordion-level aggregate event — consumers must listen per-panel or derive an aggregate by combining panel outputs. Canonical `expandedChange` fires from the accordion root with `{ itemId: string, expanded: boolean }`. Material fires per-panel outputs (opened / closed / expandedChange) with no item-id payload — the identity is implicit via the panel reference in the template. Consumers building single-open accordions must wire open/close handlers manually per panel rather than subscribing to one root event. Source: github.com/angular/components blob src/cdk/accordion/accordion-item.ts (fetched 2026-05-04).
events[itemActivate] omitted Canonical `itemActivate` fires when the user activates a trigger regardless of whether the state actually changes (useful for non-collapsible single-open mode). MatExpansionPanel exposes no equivalent — there is no output for "header clicked / Enter pressed without a state transition". Consumers who need activation-without-state-change tracking must wire a click handler on mat-expansion-panel-header themselves. Source: github.com/angular/components blob src/material/expansion/expansion-panel.ts (fetched 2026-05-04).
anatomy[panel] extended + `MatExpansionPanelContent` structural directive (`matExpansionPanelContent` on an <ng-template>) enables lazy mounting — panel content is not rendered until the panel is first expanded. Mirrors the canonical lazyMountThreshold performance concern but expressed as an explicit opt-in directive rather than automatic threshold behaviour. MatExpansionPanel also emits `afterExpand` and `afterCollapse` outputs that fire after the CSS transition completes, enabling post-animation DOM work. The canonical anatomy does not model a lazy-content directive or post-animation lifecycle outputs. Material ships matExpansionPanelContent as a first-class lazy-mount pattern (matching the canonical 50kb lazyMountThreshold rationale) and afterExpand / afterCollapse for fine-grained animation lifecycle hooks. Source: github.com/angular/components blob src/material/expansion/expansion-panel.ts (fetched 2026-05-04).
Why this audit reads the way it does

Angular Material Expansion Panel ships a batteries-included compound component rather than the primitive-per-slot decomposition the canonical anatomy describes. The most significant divergences: 1. No heading wrapper — MatExpansionPanelHeader uses role="button" directly on its host element with no h2–h6 wrapper, breaking APG heading-navigation conformance. Consumers must add the heading wrapper themselves. 2. No variant system — displayMode (default/flat) is a spacing axis, not the bordered/contained/flush variant taxonomy the canonical describes. 3. No collapsible input — single-open mode always allows collapse; the canonical "lock last open panel open" pattern is absent. 4. No root-level expandedChange event — state events are per-panel (opened / closed / expandedChange); aggregate tracking requires manual wiring. Notable Material strengths relative to the canonical: 1. togglePosition input — the indicator can be placed before or after the label, which the canonical icon slot does not model. 2. collapsedHeight / expandedHeight inputs — fine-grained header height control not in canonical. 3. matExpansionPanelContent lazy-mount directive — explicit opt-in lazy render matching the canonical lazyMountThreshold rationale. 4. afterExpand / afterCollapse outputs — post-transition lifecycle hooks enabling precise DOM work after animation completes.

headlessui Disclosure / DisclosureButton / DisclosurePanel
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
const items = [
{ id: 'shipping', heading: 'Shipping policy', content: 'We ship worldwide in 3–5 days.' },
{ id: 'returns', heading: 'Returns', content: 'Return within 30 days for a full refund.' },
{ id: 'contact', heading: 'Contact us', content: 'Email support@example.com.' },
];
export function FaqAccordion() {
return (
<div className="divide-y border rounded-md">
{items.map((item) => (
<Disclosure key={item.id}>
{({ open }) => (
<>
{/* canonical: trigger must be wrapped in a heading */}
<h3>
<DisclosureButton className="flex w-full items-center justify-between px-4 py-3 text-left">
<span>{item.heading}</span>
<svg
aria-hidden="true"
className={`h-5 w-5 transition-transform ${open ? 'rotate-180' : ''}`}
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
/>
</svg>
</DisclosureButton>
</h3>
<DisclosurePanel className="px-4 pb-4 text-sm">
{item.content}
</DisclosurePanel>
</>
)}
</Disclosure>
))}
</div>
);
}

Divergence

From Type → To Rationale
anatomy[root] omitted Headless UI ships no Accordion root component. There is no shared container that manages the list of items, enforces single-vs-multi expansion, or owns the accordion's ArrowDown/Up keyboard walk. Consumers compose N independent Disclosure components inside a plain div. The single-expansion contract (close the previously-open item when a new one opens) must be implemented manually by the consumer using controlled `open` state on each Disclosure — the library provides no coordination mechanism. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
anatomy[item] omitted No Accordion.Item or equivalent wrapper exists. Each Disclosure is an independent component with its own isolated open/closed state. Per-item state grouping (the header-trigger-panel unit) is a consumer responsibility. This means the canonical item-level `data-state` attribute (closed | expanding | expanded | collapsing) is absent; Headless UI surfaces only `data-open` on the Disclosure and individual button data attributes. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
anatomy[header] omitted Headless UI provides no heading-wrapper component. The canonical APG requirement that each accordion trigger be wrapped in a heading element (`<h2>`–`<h6>`) is invisible to the library; consumers must supply the heading themselves. DisclosureButton renders a `<button>` directly and does not enforce or suggest the heading wrapper in its API or docs. Omitting the wrapper is the most common a11y violation when composing Disclosure into an accordion. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
anatomy[icon] omitted No chevron or icon primitive is shipped. Icon rendering and rotation are consumer responsibilities. DisclosureButton exposes an `open` render prop (and `data-open` attribute) as the styling hook; the consumer applies CSS rotation or swaps glyphs. No default icon is included with the library. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
anatomy[panel] extended + CloseButton — a named export (`import { CloseButton } from '@headlessui/react'`) that closes the nearest DisclosurePanel ancestor when clicked. Useful for "close" affordances inside expanded panel content (e.g. navigation menus that dismiss on item selection). Also available as `useClose()` hook for imperative control inside deeply nested components. The canonical accordion anatomy has no `close-button` slot inside the panel — panels close via the trigger button in the header. Headless UI extends this with a CloseButton primitive targeted at content inside the panel that must close its own parent Disclosure (e.g. a link inside a nav disclosure menu). This is specific to the Disclosure-as-nav pattern and has no canonical equivalent in accordion use-cases. Included here because CloseButton is a named export users will encounter. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
axes.variants[bordered] omitted Headless UI is intentionally unstyled; no bordered, contained, or flush visual variants exist. All layout, border, gap, and radius treatment is consumer CSS (typically Tailwind utilities). The library ships no design tokens or utility classes. This omission covers all three canonical variants: bordered, contained, and flush. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
axes.variants[contained] omitted See axes.variants[bordered] — same rationale applies. All visual container treatment is consumer CSS. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
axes.variants[flush] omitted See axes.variants[bordered] — same rationale applies. The flush (no-border, no-gap) layout mode is consumer CSS. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
axes.properties[multi] omitted There is no `multi` prop and no accordion-level expansion controller. Single-open (mutually-exclusive) behaviour requires the consumer to lift state and pass a controlled `open` value plus a shared setter to each Disclosure, resetting the others on every toggle. Multi-open is the default because each Disclosure is fully independent. The canonical distinction between the two modes is entirely a consumer concern in this library. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
axes.properties[collapsible] omitted No `collapsible` prop. Each Disclosure is always independently collapsible — there is no non-collapsible single-open mode. The canonical `collapsible: false` behaviour (prevent the last-open item from closing in single-open mode) must be enforced by consumer state logic. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
axes.properties[density] omitted No density prop. Padding and spacing are consumer CSS. Headless UI stops at the accessibility primitive layer. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
a11yAcceptance.keyboardWalk omitted The canonical APG accordion keyboard contract includes ArrowDown / ArrowUp to move focus between item triggers and Home / End to jump to the first / last trigger. Headless UI Disclosure supports only Enter / Space on DisclosureButton; inter-item arrow navigation is not implemented because the library has no shared accordion root to own the roving tabindex logic. Consumers building an accessible accordion must wire this keyboard navigation themselves. Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
axes.states.data[expanding] reshaped data-enter / data-leave / data-closed transition attributes on DisclosurePanel (opt-in via `transition` prop on DisclosurePanel); no expanding/collapsing state names Canonical defines four data states: closed, expanding, expanded, collapsing. Headless UI's transition system uses `data-enter` (panel opening) and `data-leave` (panel closing), plus `data-closed` — all present only when the `transition` prop is set to true on DisclosurePanel. These attributes are meant to drive CSS transition classes via attribute selectors, not to represent a stable named state machine. There are no `data-expanding` or `data-collapsing` equivalents; the intermediate animation states are implicit in the CSS transition lifecycle rather than explicitly named on the element. Framer Motion and React Spring are supported via the `static` prop on DisclosurePanel (bypasses unmount management for external animation libraries). Source: https://headlessui.com/react/disclosure (v2.2.10, 2026-05-05).
Why this audit reads the way it does

Headless UI does not ship an Accordion primitive. The intended pattern is to compose N independent Disclosure components. This introduces three structural gaps against the canonical accordion: 1. No shared root — no single-vs-multi expansion controller, no roving-tabindex keyboard walk (ArrowDown/Up/Home/End), and no accordion-level state ownership. Consumers must implement all of this manually when lifting state across Disclosures. 2. No heading wrapper — the canonical APG requirement to wrap each trigger in a heading element is invisible to the library API and absent from Disclosure docs. This is the primary a11y risk when composing Disclosures into an accordion. 3. No transition state machine — the canonical four-state model (closed / expanding / expanded / collapsing) collapses to a binary open/closed toggle with opt-in CSS transition hooks (data-enter / data-leave / data-closed). Coordinated animation (chevron rotation + panel height transition sharing a single duration token) is entirely consumer work. Notable addition vs canonical: CloseButton (and useClose hook) for imperative panel closure from within panel content. This targets the Disclosure-as-nav-menu use-case, not accordion. These gaps are consistent with Headless UI's design philosophy: ship the minimal accessible primitive, defer all composition, styling, and state coordination to the consumer. Audited against @headlessui/react v2.2.10 (released 2026-04-07). Version sourced from: https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/CHANGELOG.md

radix Accordion
import * as Accordion from '@radix-ui/react-accordion';
import { ChevronDownIcon } from '@radix-ui/react-icons';
<Accordion.Root type="single" defaultValue="item-1" collapsible>
<Accordion.Item value="item-1">
<Accordion.Header asChild>
<h3>
<Accordion.Trigger>
Is it accessible?
<ChevronDownIcon aria-hidden />
</Accordion.Trigger>
</h3>
</Accordion.Header>
<Accordion.Content>
<p>Yes. Radix wires aria-expanded and aria-controls automatically.</p>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Header asChild>
<h3>
<Accordion.Trigger>
Is it unstyled?
<ChevronDownIcon aria-hidden />
</Accordion.Trigger>
</h3>
</Accordion.Header>
<Accordion.Content>
<p>Yes. Style it with Tailwind, CSS Modules, or any approach you prefer.</p>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>

Divergence

From Type → To Rationale
anatomy[panel] renamed Accordion.Content Same structural role (`role="region"` with `aria-labelledby` wired to the corresponding Trigger). Radix uses "Content" following its own primitives vocabulary (Dialog.Content, Popover.Content, Collapsible.Content). The canonical term "panel" mirrors the APG accordion pattern; Radix diverges for internal naming consistency. CSS variables `--radix-accordion-content-width` and `--radix-accordion-content-height` are exposed on the element to enable CSS-driven expand/collapse animations — the canonical `motion` block describes the same intent with design tokens. Source: https://www.radix-ui.com/primitives/docs/components/accordion#content
anatomy[trigger] renamed Accordion.Trigger Same interactive role (`<button>` via CollapsiblePrimitive.Trigger). Radix uses "Trigger" across all its disclosure primitives (Collapsible, Popover, Select). The component auto-wires `aria-expanded` and `aria-controls` to the matching Content element, satisfying the canonical APG contract. The `aria-disabled` attribute is set conditionally — only when the item is currently open AND the root is `type="single"` with `collapsible={false}`, preventing the user from collapsing the last open item while keeping the trigger focusable. This matches the canonical a11y contract; native `disabled` is never used. Source: https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/src/accordion.tsx
anatomy[icon] omitted Radix ships no chevron or indicator sub-component for accordion items. Consumers supply their own icon element inside Accordion.Trigger and style its rotation via the `[data-state="open"]` selector on the Trigger or on the icon element itself. The canonical icon slot is decorative and optional; Radix makes the consumer responsible for the entire visual affordance. Source: https://www.radix-ui.com/primitives/docs/components/accordion
anatomy[header] reshaped Accordion.Header renders <h3> by default (Primitive.h3 in source). The recommended pattern for controlling heading level is asChild with an explicit <h2>/<h3>/<h4> child (e.g. <Accordion.Header asChild><h2>…</h2></Accordion.Header>). The canonical contract requires that the heading level be chosen relative to the document outline (h2 for a top-level accordion, h3 when nested). Radix hard-codes `<h3>` as the default element for Accordion.Header rather than exposing a `level` prop. Consumers working inside an h2-level section must use `asChild` to override to `<h2>` — a two-step pattern not required by the canonical anatomy. The `asChild` approach is fully conformant but the fixed default of `<h3>` will violate heading-order in document outlines where h3 is not correct if used without an override. Source: https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/src/accordion.tsx
anatomy[panel] extended + forceMount prop on Accordion.Content (boolean). When true, keeps the content mounted in the DOM even when the item is closed, bypassing the default unmount-on-collapse behaviour. Enables CSS animation exit transitions and avoids re-mount cost for heavy panel content. The canonical anatomy has no `forceMount` concept because it is framework-neutral — mount/unmount lifecycle is a React-specific concern. Radix exposes `forceMount` via CollapsiblePrimitive.Content passthrough to solve the common pattern of animating the exit transition (which requires the element to remain mounted while the close animation runs). Without `forceMount`, the element is removed from the DOM immediately on close and the CSS exit animation cannot play. Source: https://www.radix-ui.com/primitives/docs/components/accordion#content
axes.properties[multi] reshaped type prop on Accordion.Root; values: "single" | "multiple". When type="single", collapsible prop (boolean) is also available. Canonical expresses the expansion mode as a boolean `multi` property on the root. Radix reshapes this into a required string enum `type` that must be "single" or "multiple". The `collapsible` boolean only applies when `type="single"` — Radix enforces this at the TypeScript level via a discriminated union on the Root prop interface, whereas the canonical anatomy lists `multi` and `collapsible` as independent properties. Passing `collapsible` with `type="multiple"` has no effect at runtime. Source: https://www.radix-ui.com/primitives/docs/components/accordion#root
axes.properties[density] omitted Radix is an unstyled primitive and ships no density prop. Trigger padding, panel padding, and item gap are entirely consumer-controlled via CSS classNames. The canonical `comfortable | compact` density axis is a design-system convention layered above the primitive. Source: https://www.radix-ui.com/primitives/docs/components/accordion
axes.variants[bordered] omitted Radix ships no visual variant system (bordered / contained / flush). All three canonical variants are achievable through consumer CSS targeting the data attributes (`[data-state]`, `[data-orientation]`) Radix exposes, but they are not bundled. This is the same trade-off as Radix Tabs — unstyled by design, variant appearance is a consumer concern. Source: https://www.radix-ui.com/primitives/docs/components/accordion
axes.properties[multi] extended + orientation prop on Accordion.Root ("vertical" | "horizontal", default "vertical"). Drives data-orientation on all sub-components and switches ArrowDown/Up to ArrowRight/Left keyboard navigation accordingly. The canonical accordion anatomy is vertically-stacked; horizontal orientation is not described in the canon. Radix exposes `orientation` as a first-class prop enabling horizontal accordion layouts with matching keyboard navigation (ArrowLeft/Right instead of ArrowDown/Up). This is a genuine extension beyond what the canonical anatomy covers. Source: https://www.radix-ui.com/primitives/docs/components/accordion#root
axes.properties[multi] extended + disabled prop on Accordion.Root (boolean). When true, disables all items in the accordion simultaneously, setting data-disabled on Root, every Item, Header, Trigger, and Content. The canonical anatomy exposes `disabled` only at the item level (via the trigger's aria-disabled hint). Radix extends this with a root-level `disabled` prop that disables the entire accordion in one declaration — a convenience for form-disabled scenarios. The canonical anatomy has no equivalent root-level disabled axis; this is an addition Radix makes to simplify controlled disabled states across all items. Source: https://www.radix-ui.com/primitives/docs/components/accordion#root
events[expandedChange] reshaped onValueChange on Accordion.Root. type="single": (value: string) => void — value is the open item's string id, or empty string when all items are closed (collapsible=true) or the previously-open item id when collapsible=false. type="multiple": (value: string[]) => void — value is the array of all currently open item ids. Canonical fires a per-item event `{ itemId, expanded }` — one event per item whose state changes. Radix fires a single root-level callback with the full new open-set after each interaction. For `type="single"` the payload is the open item id (not a delta); for `type="multiple"` it is the complete array of open ids. Consumers deriving per-item change events must diff the previous and next values themselves. The payload shape mismatch means canonical event consumers cannot use the Radix callback without an adapter. Source: https://www.radix-ui.com/primitives/docs/components/accordion#root
events[itemActivate] omitted Radix exposes no distinct activation event separate from `onValueChange`. Activating an already-open item in `type="single"` non-collapsible mode produces no `onValueChange` call (state is unchanged). Consumers who need to distinguish user intent from state changes must attach an `onClick` handler directly to `Accordion.Trigger`; Radix provides no dedicated lifecycle hook for the activation edge. Source: https://www.radix-ui.com/primitives/docs/components/accordion#root
Why this audit reads the way it does

Radix Accordion (@radix-ui/react-accordion@1.2.12) is an unstyled primitive that correctly implements the APG accordion pattern: heading-wraps-trigger, aria-expanded auto-wired, aria-controls linking Trigger to Content, role="region" on Content, and the ArrowDown/Up/Home/End keyboard model. The anatomy renames (panel→Content, trigger→Trigger) follow Radix's own primitives vocabulary consistently. Three structural divergences are notable. First, the Header heading level: Radix defaults to <h3> (hard-coded as Primitive.h3 in source) and relies on asChild for level overrides — consumers who skip asChild will violate heading-order in documents where h3 is not correct. Second, the expansion-mode API: canonical uses a boolean multi prop; Radix uses a required discriminated type enum ("single" | "multiple") with collapsible scoped to single-mode only. Third, the aria-disabled behaviour on Trigger: Radix sets aria-disabled only in the narrow case where the item is currently open AND collapsible=false in single-mode, keeping non-collapsible triggers focusable without advertising them as disabled in the general case. The forceMount extension on Content is React-specific infrastructure not representable in the framework-neutral canonical anatomy. The unstyled nature means all three canonical visual variants and the density axis are consumer responsibilities. The orientation extension (horizontal accordion) and root-level disabled prop go beyond the canonical anatomy.

react-aria DisclosureGroup
import {
DisclosureGroup,
Disclosure,
DisclosurePanel,
Heading,
Button,
} from 'react-aria-components';
// Single-open (default — allowsMultipleExpanded omitted)
<DisclosureGroup>
<Disclosure id="item-1">
<Heading>
<Button slot="trigger">
<ChevronRight aria-hidden="true" />
What is React Aria?
</Button>
</Heading>
<DisclosurePanel>
React Aria is a library of unstyled, accessible UI primitives for React.
</DisclosurePanel>
</Disclosure>
<Disclosure id="item-2">
<Heading>
<Button slot="trigger">
<ChevronRight aria-hidden="true" />
How do I install it?
</Button>
</Heading>
<DisclosurePanel>
Run <code>npm install react-aria-components</code>.
</DisclosurePanel>
</Disclosure>
</DisclosureGroup>
// Multi-open
<DisclosureGroup allowsMultipleExpanded>
{/* same Disclosure children */}
</DisclosureGroup>
// Controlled
<DisclosureGroup
expandedKeys={openKeys}
onExpandedChange={setOpenKeys}
>
{/* … */}
</DisclosureGroup>

Divergence

From Type → To Rationale
anatomy[root] renamed DisclosureGroup React Aria names the accordion root `DisclosureGroup` — a grouping container for related `Disclosure` items. There is no component named `Accordion`. The rename is intentional: the library models accordion as a composition of disclosure primitives rather than a first-class accordion abstraction. `DisclosureGroup` renders a `<div>` with no landmark role; the canonical root is equally presentational, so the semantic contract is preserved. Source: https://react-aria.adobe.com/DisclosureGroup (fetched 2026-05-05)
anatomy[item] renamed Disclosure The per-item wrapper is `Disclosure`, not a generic `Item`. Each `Disclosure` requires an `id` prop (type `Key`) when used inside `DisclosureGroup` so the group can manage controlled/uncontrolled expansion keys. Standalone `Disclosure` usage outside a group does not require `id`. Source: https://react-aria.adobe.com/Disclosure (fetched 2026-05-05)
anatomy[header] reshaped Heading (from react-aria-components) wrapping Button slot="trigger" The canonical `header` is a heading element wrapping the trigger button. React Aria ships a `Heading` component (from react-aria-components) as the heading wrapper; it accepts a `level` prop (1–6) so the consumer controls the document-outline level. The docs do not export a pre-composed `DisclosureHeader` from the package itself — the pattern shown in docs is a consumer helper named `DisclosureHeader` that the consumer defines as `Heading` + `Button slot="trigger"`. The two-piece composition is structurally equivalent to the canonical single `header` slot but exposes the heading-level concern as an explicit prop rather than a convention. Source: https://react-aria.adobe.com/Disclosure#anatomy (fetched 2026-05-05)
anatomy[trigger] reshaped Button with slot="trigger" inside Heading The trigger is a React Aria `Button` component with the reserved `slot` prop set to `"trigger"`. This wires the button into the `Disclosure` context so it controls the expansion state and receives `aria-expanded` and `aria-controls` automatically. The consumer must pass `slot="trigger"` explicitly; omitting it breaks the wiring. This is different from the canonical `trigger` slot which is an implicit child of `header`. Source: https://react-aria.adobe.com/Disclosure#anatomy (fetched 2026-05-05)
anatomy[icon] reshaped children of Button slot="trigger" (no named slot) React Aria provides no named icon slot. The chevron/icon is placed as children inside the `Button slot="trigger"` and animated via `[data-expanded] svg { transform: rotate(90deg) }` CSS. This matches the canonical intent (decorative, aria-hidden, state driven by aria-expanded) but removes any slot boundary — the icon is fully consumer-composed inside children. Source: https://react-aria.adobe.com/Disclosure (fetched 2026-05-05)
anatomy[panel] reshaped DisclosurePanel (role defaults to "group", not "region") `DisclosurePanel` renders a `<div>` with `role="group"` by default. The canonical specifies `role="region"` with `aria-labelledby`. React Aria's default of `role="group"` is intentional: the APG Disclosure pattern does not mandate `role="region"` (unlike Accordion), and `role="region"` creates an extra landmark that bloats SR navigation when there are many panels. Consumers can opt into `role="region"` via the `role` prop on `DisclosurePanel`; the canonical's `aria-labelledby` wiring is then applied automatically by React Aria using the trigger's id. The panel `hidden` / `display:none` management on collapse is handled by the library. Source: https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Disclosure.tsx (fetched 2026-05-05)
axes.variants[bordered] omitted React Aria is an unstyled primitive library. Variant styling (bordered / contained / flush) is the consumer's responsibility via CSS and the exposed `data-*` attributes. The `DisclosureGroup` and `Disclosure` components expose no `variant` prop. Source: https://react-aria.adobe.com/DisclosureGroup (fetched 2026-05-05)
axes.variants[contained] omitted Same as `bordered` — no `contained` variant shipped. Consumer CSS. Source: https://react-aria.adobe.com/DisclosureGroup (fetched 2026-05-05)
axes.variants[flush] omitted Same as `bordered` — no `flush` variant shipped. Consumer CSS. Source: https://react-aria.adobe.com/DisclosureGroup (fetched 2026-05-05)
axes.properties[multi] renamed allowsMultipleExpanded (boolean on DisclosureGroup) The canonical `multi` boolean maps directly to `allowsMultipleExpanded` on `DisclosureGroup`. The prop name is more explicit about the permitted concurrent expansions. Default is single-open (false). Only meaningful on the group container; standalone `Disclosure` has no multi/single concept. Source: https://react-aria.adobe.com/DisclosureGroup (fetched 2026-05-05)
axes.properties[collapsible] omitted React Aria `DisclosureGroup` has no `collapsible` prop. In single-open mode the currently-expanded item can always be collapsed by activating its trigger (unlike some implementations that lock at least one item open). The canonical `collapsible: false` mode (single-open, no collapse) is not supported — React Aria always allows closing the active item. Consumers needing a "always-one-open" contract must enforce it in `onExpandedChange` by preventing the empty-set case. Source: https://react-aria.adobe.com/DisclosureGroup (fetched 2026-05-05)
axes.properties[density] omitted No `density` prop. Spacing is consumer CSS. The unstyled primitive exposes no comfortable/compact density axis. Source: https://react-aria.adobe.com/DisclosureGroup (fetched 2026-05-05)
axes.states.data[expanding] omitted React Aria does not expose a transient `expanding` state. The `Disclosure` data attributes are `data-expanded` (boolean) and `data-disabled`. Intermediate animation states (expanding / collapsing) are tracked only by CSS transitions on the consumer's stylesheet; there is no JS-level `expanding` render-prop or data attribute. Source: https://react-aria.adobe.com/Disclosure (fetched 2026-05-05)
axes.states.data[collapsing] omitted Same as `expanding` — no `collapsing` transient state exposed. Panel height animation is handled by the consumer's CSS transition. React Aria removes the `hidden` attribute (or applies display logic) immediately on collapse without a transitional state hook. Source: https://react-aria.adobe.com/Disclosure (fetched 2026-05-05)
axes.states.interactive[hover] extended + React Aria exposes `data-hovered` on the trigger `Button` (via `useHover`), enabling pointer-device hover styling without needing CSS `:hover` (which has known issues on touch devices). The canonical documents `hover` as an interactive state; React Aria surfaces it as a data attribute rather than a CSS pseudo-class. React Aria normalises hover across pointer types using `useHover` internally. `data-hovered` is set on the trigger button; consumers style from `[data-hovered]`. This is an extension over the canonical which leaves hover styling to CSS `:hover`. Source: https://react-aria.adobe.com/Disclosure (fetched 2026-05-05)
a11yAcceptance.keyboardWalk[arrowUpDown] omitted React Aria `DisclosureGroup` does not implement ArrowDown / ArrowUp navigation between disclosure triggers. The APG accordion pattern lists this as optional-but-recommended; React Aria opts out entirely. Focus moves between triggers via Tab/Shift+Tab only. Consumers requiring arrow navigation must implement a custom `useRovingTabIndex` on top of `DisclosureGroup`. Source: https://react-aria.adobe.com/DisclosureGroup (fetched 2026-05-05)
a11yAcceptance.keyboardWalk[homeEnd] omitted React Aria `DisclosureGroup` does not implement Home / End to jump to first / last trigger. As with ArrowDown/Up, this APG-optional shortcut is not part of the DisclosureGroup keyboard contract. Source: https://react-aria.adobe.com/DisclosureGroup (fetched 2026-05-05)
events[expandedChange] reshaped onExpandedChange on Disclosure: (isExpanded: boolean) => void; onExpandedChange on DisclosureGroup: (keys: Set<Key>) => any The canonical `expandedChange` fires `{ itemId, expanded }`. React Aria splits this across two levels: (1) `Disclosure.onExpandedChange` fires a plain boolean for the individual item's new state — no itemId in the payload because the callback is on the item itself. (2) `DisclosureGroup.onExpandedChange` fires the full `Set<Key>` of currently expanded keys after any change, from which consumers derive per-item deltas. The group-level callback is the closer analogue to the canonical multi-item event but requires a diff against previous state to reconstruct per-item semantics. Source: https://react-aria.adobe.com/DisclosureGroup (fetched 2026-05-05)
events[itemActivate] omitted React Aria does not expose a discrete `itemActivate` event. Activation (user pressing the trigger) is observable only through `onExpandedChange` — if the state changes — or by attaching `onPress` directly to the `Button slot="trigger"` child. The canonical `itemActivate` intent (track user activation independent of state change) is achievable via `onPress` on the trigger button but is not modelled as a first-class group-level event. Source: https://react-aria.adobe.com/Disclosure (fetched 2026-05-05)
motion.durations reshaped CSS transitions on consumer stylesheet (data-expanded attribute selectors) React Aria ships no motion duration tokens. Panel height animation and chevron rotation are driven by consumer CSS transitions keyed off `[data-expanded]` on the `Disclosure` root. The vanilla CSS starter kit uses a 250ms panel transition; the Tailwind starter kit makes duration configurable via Tailwind tokens. The canonical motion.duration.base and motion.duration.fast tokens are not referenced by the library. Source: https://react-aria.adobe.com/Disclosure (fetched 2026-05-05)
motion.reducedMotionFallback reshaped @media (prefers-reduced-motion) in consumer CSS React Aria documentation notes that panel transitions respect `prefers-reduced-motion` — but the implementation is entirely in the consumer's CSS using `@media (prefers-reduced-motion: reduce)`. The library does not apply or suppress transitions in JS; it exposes no `reducedMotionFallback` prop or hook. This is consistent with the library's philosophy of owning behaviour, not styling. Source: https://react-aria.adobe.com/Disclosure (fetched 2026-05-05)
Why this audit reads the way it does

React Aria does not ship an `Accordion` component. The equivalent is a composition of `DisclosureGroup` (root/container) + `Disclosure` (per-item) + `DisclosurePanel` (panel) + React Aria's `Heading` + `Button slot="trigger"` (trigger inside heading). This decomposition reflects the library's philosophy: accordion is modelled as a group of disclosure primitives, not as a first-class compound component. The most significant structural divergences from canon are: 1. No named `icon` slot — the chevron lives as arbitrary children inside the trigger button; animation is CSS-only via `[data-expanded]`. 2. `DisclosurePanel` defaults to `role="group"` rather than `role="region"`, avoiding landmark bloat for long accordion lists. Consumers opt into `role="region"` when landmark navigation for individual panels is desired. 3. Arrow-key navigation between triggers is absent — DisclosureGroup relies on Tab/Shift+Tab only, diverging from the APG optional-but-recommended roving focus contract. 4. No `collapsible: false` mode — single-open panels can always be collapsed. 5. Variant styling (bordered / contained / flush) and density are consumer CSS; the primitive exposes no styling axis.

Designer

Figma anatomy

Slot Figma type Hint
root frame Auto-layout vertical frame; gap and border treatment from variant
item instance Accordion item component instance; expanded state via component property
header text Heading text style; same level for every item in the accordion
trigger instance Button component instance filling the header; bound to expanded state
icon instance Icon component instance; rotation or swap bound to expanded state
panel frame Auto-layout vertical frame; visibility bound to expanded state of the parent item
Designer

Token usage per slot

root
spacing
  • gapspacing.tight
color
  • bordercolor.border.subtle
item
radius
  • cornerradius.md
color
  • backgroundcolor.surface.bg
header
color
  • foregroundcolor.text.primary
typography
  • sizetext.md
  • weightweight.semibold
trigger
spacing
  • paddingspacing.compact
  • gapspacing.compact
color
  • ringcolor.border.focus
icon
color
  • foregroundcolor.text.muted
panel
spacing
  • paddingspacing.compact
color
  • foregroundcolor.text.primary
typography
  • sizetext.md
  • lineHeightleading.normal
Both

Figma ↔ Code property map

FigmaKindCodeNotes
VariantEnumvariantMaps bordered / contained / flush.
MultiBooleanmultiToggles single-vs-multi-open behaviour. Default false (single-open) for FAQ patterns; true for settings groups.
CollapsibleBooleancollapsibleIn single-open mode, allows the currently-open item to collapse (state with no items expanded). Multi-mode always allows collapse; this property is only meaningful for single-mode.
DensityEnumdensitycomfortable / compact. At and below `breakpoint.sm` density compact is the canonical default.
Item CountEnumitems.lengthFigma exposes 2/3/4/5/6+ item counts as a Variant for preview-time layout review. Code accepts an array of item definitions.
Has IconBooleanhasIconToggles the trigger-icon (chevron / plus-minus) visibility per item.
Default Open ItemsEnumdefaultOpenFigma exposes "first item open", "all items open", "no items open" as Variant for preview; in code it's a configuration prop (single-string id for single-mode, array of ids for multi-mode).
Designer

Motion

TransitionDuration token
expandmotion.duration.base
collapsemotion.duration.base
chevronRotatemotion.duration.fast
Easing
motion.easing.standard
Reduced motion
Instant (jump cut)
Designer

Responsive behaviour

BreakpointChange
breakpoint.smAt and below, density compact becomes the canonical default — narrow viewports cannot accommodate comfortable density without crowding. Trigger padding reduces; panel padding stays consistent. Variant `flush` (no borders, no item gap) is preferred to preserve vertical space.
breakpoint.mdAbove this width, density and variant render as authored. Items render at their authored gap and border treatment.
Both

Internationalisation

RTL · mirroring

Vertical orientation is direction-neutral (top-to-bottom stacking unchanged). Trigger inline-content (heading-text · chevron) reverses logical order — the chevron moves from inline-end (visual right in LTR) to inline-end (visual left in RTL) via logical positioning. ArrowDown / ArrowUp keyboard navigation is unchanged. Chevron rotation is direction-neutral (down/up arrows are symmetric); plus-minus glyph is also direction-neutral.

Text expansion

Trigger heading text wraps to additional lines under heavy expansion (DE / RU / FI). Panel content follows its own text-flow. Density compact risks crowding long-text headings; density comfortable is the safer default in long-text locales. Multi-line trigger headings continue to behave correctly with the chevron icon — the chevron aligns with the first line of the heading by canonical convention.

Both

Variants, properties, states

Variants

Structurally different versions of the component.

bordered contained flush

Properties

The same component, parameterised.

PropertyType
multi boolean
collapsible boolean
density comfortable | compact

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
closedexpandingexpandedcollapsing
Both

State transitions

FromToTrigger
closedexpandingUser activates the trigger (Enter / Space / click) on a closed item. `aria-expanded` flips to true; the panel begins its enter animation.
expandingexpandedThe expand animation completes (or, under prefers-reduced-motion reduce, immediately). The panel is fully visible and reachable by keyboard via Tab.
expandedcollapsingUser activates the trigger on an expanded item (when `collapsible: true` for single-open variant, or always for multi-open variant); or another item is activated in single-open non-collapsible mode and this item collapses to make way.
collapsingclosedThe collapse animation completes (or immediately under reduced motion). The panel is removed from the accessibility tree and from the keyboard tab order via the `hidden` attribute or `display: none`.
Both

Figma↔Code mismatches

  1. 01
    Figma

    Accordion item header drawn as plain text with a chevron icon

    Code

    A real `<button>` inside a `<h2>`/`<h3>`/`<h4>` element with `aria-expanded`

    Consequence

    Designers may treat the header as a styled label with a chevron affordance. Implementations following the Figma file ship a `<div>` with click handler — the click works for mouse users but the entire keyboard, SR, and APG contract is broken (no focus, no Enter/Space activation, no announced state, no heading semantics).

    Correct

    Document the canonical structure as heading-wraps-button. The Figma component must encode "header is heading; trigger is button" — even if the visual treatment puts no button-styling on the trigger, the underlying element is a real `<button>` inside a real heading.

  2. 02
    Figma

    Single-open and multi-open drawn as the same component

    Code

    A `multi` boolean property toggling whether multiple panels can be open simultaneously

    Consequence

    Designers do not differentiate the two modes in the design file; developers ship one or the other depending on what the Figma mock implies. The mode is canonical-meaningful — single-open accordions imply mutually-exclusive disclosure (FAQ patterns); multi-open imply independent collapsible regions (settings groups). Mixing them confuses users.

    Correct

    Treat `multi` as a first-class property on the canonical reference. Designers and developers both consult the canon to confirm which mode they're using; the Figma component carries `multi` as a Boolean property.

  3. 03
    Figma

    Animated chevron rotation drawn but no panel-height transition

    Code

    Both the chevron rotation AND the panel height-transition animate together

    Consequence

    Designers animate the chevron in mocks but the panel appears instantly (Figma cannot easily mock smooth height-transitions). Developers shipping faithful-to-mock get jumpy panel transitions; SR users get no announcement while the visible content shifts.

    Correct

    Document both animations as canonical: chevron rotation + panel height-transition share the same duration token (per ADR-007 motion). Implementations using `[hidden]` toggle without animation are valid for `prefers-reduced-motion: reduce`.

  4. 04
    Figma

    Each item header drawn at a different heading level for "visual variety"

    Code

    All item headers at the same heading level (consistent with document outline)

    Consequence

    Designers may use h2 for important items, h3 for secondary, h4 for tertiary in the same accordion to create visual hierarchy. Implementations following the design break SR heading navigation (the user expects a flat list of siblings; gets a hierarchy). Heading levels in an accordion communicate document structure, not item importance.

    Correct

    All accordion item headers MUST be the same heading level. Choose the level relative to the document outline (h2 if the accordion is a top-level region; h3 if nested under a top-level h2). Communicate item importance through content order, not heading hierarchy.

Both

Contracts

Non-negotiable contracts

  1. APGAPG: Accordion pattern — Heading element wrapping the button

    Each accordion trigger is wrapped in a heading element (`<h2>`–`<h6>`) at the appropriate document-outline level.

    Without the heading wrapper, the accordion items vanish from the AT headings list and the entire section is unreachable for screen-reader users navigating by heading. Routinely violated in production — the canonical reference documents this as a hard contract, not styling guidance.

  2. APGAPG: Accordion pattern — aria-expanded on the button

    `aria-expanded` on the trigger is the single source of truth for the open/closed state; CSS or JS state must derive from it, not the other way around.

    When `aria-expanded` is treated as a presentational mirror rather than the authoritative state, AT announcements drift from the visible state and keyboard- only flows lose orientation between expansions.

Designer

Common mistakes

Blocker

#accordion-no-aria-expanded

Trigger missing `aria-expanded` toggle

Problem

The button has no `aria-expanded` attribute. Visually the panel reveals; SR users hear "button" with no state cue, and the icon alone is not discoverable to non-sighted users.

Fix

`aria-expanded="true"` on the button when the panel is open, `false` when closed. Always pair with `aria-controls` referencing the panel's id. Style the icon from `[aria-expanded="true"]` rather than introducing a parallel `data-expanded` attribute.

Blocker

#accordion-icon-only-state-cue

Expansion state communicated only by chevron rotation

Problem

The chevron rotates on expand, but `aria-expanded` is missing or stuck at one value. Sighted users see the state; SR users do not. The icon is decorative; without `aria-expanded` it carries the entire state-meaning load.

Fix

`aria-expanded` is the source of truth for expansion state; the icon visualises it. The icon is `aria-hidden="true"`; the announced state comes from `aria-expanded`. Style icon rotation from `[aria-expanded="true"]`.

Major

#accordion-no-heading-wrap

Trigger button not wrapped in a heading element

Problem

The trigger is a `<button>` directly inside a `<div>` or a list item, not wrapped in a heading. SR users navigating by heading skip the accordion entirely; the structure lacks the canonical APG semantics.

Fix

Wrap each trigger in a heading element (`<h2>`, `<h3>`, `<h4>`) of consistent level across all items. The heading is what SR users use to navigate to and within the accordion; the button inside the heading is what carries the toggle behaviour.

Major

#accordion-multi-vs-single-confusion

Single-open mode silently switches to multi when user expects mutual exclusion

Problem

The accordion's `multi` mode is set by config but not communicated to users. Users expanding one item expect the previously-open item to collapse (single-open assumption); it does not, and the layout grows unexpectedly.

Fix

Choose `multi` mode based on content: single-open for mutually-exclusive content (FAQ patterns), multi-open for independent regions (settings groups). The mode should be visually obvious — single-open's collapse-others behaviour gives a recognisable "only one open" feeling. Avoid switching modes mid-flow; the choice is canonical per use case.

Accessibility hints
Slot Accessibility hint
root The root has no required ARIA role. APG explicitly forbids `role="region"` on the root because the regions live on the panels inside (each panel is its own region). Adding a wrapping role creates redundant landmark navigation.
item Item is a structural grouping; no ARIA role. The header and panel inside carry the canonical APG semantics (heading + button + region).
header Header is a heading element — `<h2>` to `<h4>` depending on document outline. The button inside is what carries `aria-expanded`; the heading itself does not. Using non-heading wrappers (a `<div>` with `role="heading"`) breaks APG conformance.
trigger Real `<button>` — never a `<div>` with click handler. Carries `aria-expanded` reflecting state, `aria-controls` referencing the panel id, and the accessible name from the header text. Disabled headers use `aria-disabled` to stay focusable; sequential focus skips disabled items only via roving tabindex (rare in accordion canon).
icon Decorative — `aria-hidden="true"`. Expansion state is communicated through `aria-expanded` on the trigger and through the panel's visible height; the icon is visual reinforcement only. Icon-only state cues without `aria-expanded` violate APG.
panel Apply `role="region"` with `aria-labelledby` referencing the trigger's id. The panel is hidden via the `hidden` attribute or `display: none` when collapsed (not just zero-height) so SR users do not encounter ghost content. For `motion: collapse` animations, swap `hidden` for a `aria-hidden="true"` plus `pointer-events: none` while the height transition runs, then apply `hidden` at the end.