Skip to content

Accordion

A stack of disclosure sections whose header is a row, not just a button — so actions can sit beside the toggle.

Stablev0.4.2added in v0.1.0@garn/ui/accordion
On this page

Default

The default stack — `type="single" collapsible`, one section open at a time.

Overview

Basic

The default stack — `type="single" collapsible`, one section open at a time.

Multiple

`type="multiple"` — sections toggle independently and several can stay open.

Appearance

Appearance

All five surface treatments side by side: `plain`, `divided`, `contained`, `separated`, `filled`.

Filled

`filled` up close — each section is one solid block; the header and its panel share the surface and the corner radius holds steady through open and close. The last section is toned, showing how a status tint blends into the same grey rather than lifting out of the stack.

Sizes

The `sm` / `md` / `lg` rungs — row height, inline padding and the panel-body indent all move together off the control ladder.

Indicator Start

`indicatorPosition="start"` — the caret leads, and the panel body indents past its column so the body lines up under the title rather than the caret.

States

Tones

Section `tone` — a soft tint, an accent rail that stretches as the panel opens, and a status glyph paired with visually-hidden text, so state is never colour alone.

Loading

A busy section (Spinner for the caret, `aria-busy` panel) beside `AccordionSkeleton`, the placeholder for a stack whose sections aren't known yet.

Group Controls

Toggle All

`AccordionToggleAll` expands or collapses every non-disabled section and flips its own label; the `*` key on a focused trigger does the same expand.

Progress

`AccordionProgress` derives completion from the sections marked `tone="success"` — mark one complete and the bar follows, because the number is never passed in.

Max Open

`maxOpen` — opening a third section closes the least-recently-opened one and announces it, and it never closes the section that holds focus.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add accordion
First time? Set up garn in your project
Registry
accordion
Deps
@radix-ui/react-accordionlucide-react
Registry deps
utilsuse-announceuse-controllable-stateuse-isomorphic-layout-effectuse-merged-refuse-prefers-reduced-motionbuttonprogressskeletonspinner

Import

import { Accordion, AccordionItem, AccordionHeader, AccordionTrigger, AccordionActions, AccordionContent, AccordionToggleAll, AccordionProgress, AccordionSkeleton } from "@garn/ui/accordion";

Anatomy

The parts this component is built from — compose them to assemble it.

AccordionRequired[data-slot="accordion"]

Root — sets type (single | multiple), the look axes, and the open set.

AccordionItemRequired[data-slot="accordion-item"]

One section; needs a unique value. Carries the per-section colour intent: tone (neutral | info | success | warning | danger) adds a soft tint, a tone-derived border, an inline-start accent rail that stretches as the panel opens, and a status glyph paired with visually-hidden text — never colour alone. tone="success" is what AccordionProgress counts as complete. loading marks the section busy. Note that tone lives HERE, not on the root — the root's axes are appearance / size / indicatorPosition.

AccordionHeader[data-slot="accordion-header"]

Optional layout row — compose it only when the section needs actions beside the toggle.

AccordionTriggerRequired[data-slot="accordion-trigger"]

The toggle button, inside its own heading. Takes icon, summary, and per-trigger indicator overrides (header-less).

AccordionActions[data-slot="accordion-actions"]

Controls beside the toggle. Requires an AccordionHeader as the row.

AccordionContentRequired[data-slot="accordion-content"]

The collapsible panel body. keepMounted overrides the root's lazy mounting for this panel.

AccordionToggleAll[data-slot="accordion-toggle-all"]

Expand-all / collapse-all control. Renders nothing (and dev-warns) under type="single".

AccordionProgress[data-slot="accordion-progress"]

Completion summary derived from tone="success" sections. Renders nothing until a section registers.

AccordionSkeleton[data-slot="accordion-skeleton"]

Loading placeholder rows for the stack.

Props

The component's public props and their types.

appearance
Description
Surface treatment: `plain` (a rule-between-items list), `divided`, `contained` (one bordered card), `separated` (a card per section), or `filled`.
Type
"contained" | "divided" | "filled" | "plain" | "separated"
Default
"plain"
indicatorPosition
Description
Which edge the disclosure indicator sits on.
Type
"end" | "start"
Default
"end"
size
Description
Row height + text scale, off the control ladder.
Type
"lg" | "md" | "sm"
Default
"md"
disableIndicatorRotation
Description
Keep the indicator static instead of rotating it on open.
Type
boolean
Default
false
headingLevel
Description
Heading level (2–6) the trigger's heading renders at — set it to match the surrounding outline. A nested accordion inherits its host's level plus one automatically, so you state it once at the top.
Type
2 | 3 | 4 | 5 | 6
Default
3
hideIndicator
Description
Render no indicator on any trigger.
Type
boolean
Default
false
indicator
Description
Replace the default chevron. A per-trigger `indicator` overrides this in the header-less usage; inside an `AccordionHeader`, set it here on the root.
Type
React.ReactNode
maxOpen
Description
Cap how many sections may be open at once (`type="multiple"` only). Opening past the cap closes the least-recently-opened section — never one that holds focus. Expand-all and the `*` shortcut deliberately bypass the cap.
Type
number
mount
Description
Panel mounting. `active` mounts a panel's children on first open; `eager` renders every panel up front.
Type
"active" | "eager"
Default
"active"
persistKey
Description
Persist the open set to `sessionStorage` under this key, so it survives a page visit. An explicit `value` (controlled) always wins over the restore.
Type
string
scrollOnOpen
Description
When opening pushes a header out of view, scroll it back (jump if reduced-motion).
Type
boolean
Default
false

Plus 286 inherited native <div> attributes.

AccordionItem

loading
Description
Mark the section busy — swaps a spinner for its indicator and marks the panel.
Type
boolean
Default
false
tone
Description
A status accent for this section (toned edge + status glyph + a text label).
Type
"danger" | "info" | "neutral" | "success" | "warning"
Default
"neutral"

Plus 283 inherited native HTML attributes.

AccordionTrigger

disableIndicatorRotation
Description
Keep this trigger's indicator static instead of rotating it on open.
Type
boolean
hideIndicator
Description
Render no indicator on this trigger.
Type
boolean
icon
Description
Leading glyph before the label (decorative — kept out of the accessible name).
Type
React.ReactNode
indicator
Description
Replace this trigger's indicator (header-less usage; else set it on the root).
Type
React.ReactNode
summary
Description
A muted second line under the label, inside the button (part of the name).
Type
React.ReactNode

Plus 291 inherited native HTML attributes.

AccordionContent

keepMounted
Description
Keep this panel mounted (hidden) once opened, so its form / scroll state survives a close. Overrides the root's lazy `mount="active"` for this panel.
Type
boolean
Default
false

Plus 282 inherited native HTML attributes.

AccordionToggleAll

collapseLabel
Description
Label while every section is open.
Type
React.ReactNode
Default
"Collapse all"
expandLabel
Description
Label while the stack is collapsed.
Type
React.ReactNode
Default
"Expand all"

Plus 303 inherited native HTML attributes.

AccordionProgress

format
Description
Format the read-out.
Type
(done: number, total: number) => string
Default
(done, total) => `${done} of ${total} complete`
label
Description
Visible caption — also the progressbar's accessible name.
Type
React.ReactNode
Default
"Sections complete"

Plus 280 inherited native HTML attributes.

AccordionSkeleton

count
Description
How many placeholder rows to render.
Type
number
Default
3

Plus 280 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="accordion"][data-slot="accordion-item"][data-slot="accordion-header"][data-slot="accordion-heading"][data-slot="accordion-trigger"][data-slot="accordion-trigger-label"][data-slot="accordion-trigger-summary"][data-slot="accordion-trigger-icon"][data-slot="accordion-status"][data-slot="accordion-indicator"][data-slot="accordion-indicator-toggle"][data-slot="accordion-actions"][data-slot="accordion-content"][data-slot="accordion-toggle-all"][data-slot="accordion-progress"][data-slot="accordion-skeleton"]

States

SelectorState
[data-slot=accordion-item][data-state=open]open
[data-slot=accordion-item][data-state=closed]closed
[data-slot=accordion-trigger][disabled]disabled
[data-slot=accordion][data-appearance=filled] [data-slot=accordion-item][data-tone]toned-filled
[data-slot=accordion-item][data-loading]loading
[data-slot=accordion-item][data-tone]toned
[data-slot=accordion-header]:has([data-slot=accordion-trigger]:hover)row-hover
PropertyTokenTier
rowMinHeight--garn-control-h-mdsemantic · air
rowPaddingInline--garn-control-px-mdsemantic · air
stackGap--garn-gap-stacksemantic · air
cardSurface--garn-cardbrandablesemantic
filledSurface--garn-mutedbrandablesemantic
edge--garn-borderbrandablesemantic
caretStateLayer--garn-state-hoversemantic
caretPressLayer--garn-state-activesemantic
statusAccent--garn-successbrandablesemantic
disclosureMotion--garn-motion-fastsemantic
disclosureEasing--garn-ease-standardsemantic

Private instance vars (never externalize): --acc-row-hover--acc-row-active

When to use

Long content that people scan first and read selectively — an FAQ, a settings page of grouped preferences, terms and policy sections, a stack of records where each header carries the summary and the panel carries the detail. Also when sections need group-level machinery: expand/collapse all, a completion read-out, a cap on how many stay open, or an open set that survives a page visit.

Reach for something else when

  • One independent show/hide region — use collapsible, which is the same disclosure without the stack, the roving keys, or the group parts. Switching between peer views where only one is ever relevant — use tabs. A flat set of short items with no panel body — use list or data-list and just show them. Hierarchical, indefinitely-nesting nodes — use tree, not accordions inside accordions. Rows of records where the detail is tabular and comparable — use table with expandable rows. Content that must all be visible at once (a printable policy, a form the user has to complete) — don't collapse it at all.

Overview

Accordion is a stack of disclosure sections — a header you can scan and a panel you open — for content that is genuinely long: an FAQ, a settings page, a batch of records each with detail underneath. It is the WAI-ARIA Accordion pattern, so Radix keeps the parts that must be exactly right (the arrow-key roving model, aria-expanded / aria-controls, single-versus-multiple state) and garn supplies the design-system surface on top.

The structural idea worth knowing is that the header is a row, not a button. AccordionTrigger always renders its own heading around the button, at the level headingLevel names — so the document outline is real, and a nested accordion inherits its host's level plus one automatically. Compose an AccordionHeader around it and you get a layout row instead: the heading, then AccordionActions (a link, a switch, a menu, a status badge), then the caret as the row's trailing mark. Because the actions live beside the button rather than inside it, they stay out of its accessible name, they're reachable by Tab, and using one doesn't toggle the panel. That single split is what lets a collapsed row answer most of the question — a title, a summary line of counts and timestamps, a status badge, and a row action — before anyone opens anything.

The row's interaction model is one model across both layouts: the whole row tints edge-to-edge whenever a toggle affordance is hovered (the trigger, or the redundant caret beside it), the caret carries its own slightly darker square on top, and nothing moves on press.

Beyond the look axes, the depth is in the state handling. Sections take a status tone (a soft tint, an accent rail that stretches as the panel opens, and a glyph paired with hidden text — never colour alone) and a loading flag. Panels mount lazily by default, so a never-opened panel costs nothing; keepMounted or mount="eager" opts a panel back in when its form state, its find-in-page text, or a chart's measurement needs to exist. persistKey restores the open set from sessionStorage before paint, and maxOpen caps how many sections stay open at once, closing the least-recently-opened one — never one holding focus — and announcing it. AccordionToggleAll, AccordionProgress and AccordionSkeleton are group-level parts that read the same registry the sections write to, so none of them can drift from the stack.

Guidelines

Reach for an accordion only when the content is genuinely long. Collapsing a handful of short items adds clicks for no gain — show them outright. It earns its keep when the natural flow is scan the headers, then open one.

Choose single vs multiple deliberately. single collapsible keeps the page tidy and the focus on one section; multiple lets people compare sections side by side. If multiple risks a wall of open panels, cap it with maxOpen rather than trusting restraint.

Set headingLevel to match the page. The default 3 suits a section under an h2. State it once at the top — nested accordions step down on their own.

Make the collapsed row do work. A short parallel title, a summary line carrying the counts or timestamp people are actually after, and a status tone or badge — so the stack is scannable shut. Front-load the keyword.

Put anything interactive in AccordionActions, never in the trigger. A button inside a button is invalid, and it would join the toggle's accessible name. Actions require an explicit AccordionHeader.

Don't hide essentials. A required form field or a critical warning inside a collapsed panel may never be read. If it must be found by browser search or printed, switch that panel to keepMounted (or the root to mount="eager") — lazy panels are genuinely absent from the DOM.

Style through the axes. appearance and size drive surface and density for the whole stack from the root; don't hand-pad a row or restyle the parts to fake a rung.

Best practices

Do
  • Give every AccordionItem a unique value.
  • Use type="single" collapsible for FAQ-style one-at-a-time; type="multiple" when several sections can usefully stay open.
  • Set headingLevel to match the surrounding outline — once, at the root.
  • Put links, switches, menus and badges in AccordionActions inside an AccordionHeader, never inside the trigger.
  • Use summary to make the collapsed row answer the question — counts, a timestamp, a failure tally.
  • Pair a section's tone with the status text it already renders; tone="success" is what AccordionProgress counts.
  • Set keepMounted on a panel whose form or scroll state must survive a close, and mount="eager" when find-in-page or printing has to reach every panel.
  • Cap a multiple accordion with maxOpen when a wall of open panels would be worse than a tidy one.
Don't
  • Don't put an interactive element inside AccordionTrigger — a nested button is invalid and joins the toggle's accessible name; use AccordionActions.
  • Don't use AccordionActions without an AccordionHeader — the row is what places the actions and the caret.
  • Don't collapse content the user must not miss (a required field, a critical warning) — under lazy mounting it isn't even in the DOM.
  • Don't nest primary navigation inside an accordion — it hides wayfinding.
  • Don't hand-pad rows with padding utilities to fake a size — use the size axis.
  • Don't reach for an accordion when there is exactly one region to disclose (use collapsible) or when the sections are peer views (use tabs).
  • Don't pass AccordionProgress a number — it derives completion from the sections so the two cannot disagree.

Content guidelines

  • Write section headers as short, scannable labels or questions ("Shipping & returns").
  • Keep headers parallel in phrasing and length so the stack reads as one set.
  • Front-load the header with the keyword users scan for.
  • Keep summary to one line of concrete facts — it is part of the trigger's accessible name, so it is read aloud with the title.

Troubleshooting

TypeScript error on <Accordion>, or nothing toggles.

Cause. type is required on the root, and each AccordionItem needs a unique value.

Fix. Set type="single" (add collapsible to allow closing the last one) or type="multiple", and give every item a distinct value.

A button or link inside the header toggles the panel, or gets read out as part of the section's name.

Cause. It was placed inside AccordionTrigger. A nested interactive element is invalid there and joins the button's accessible name.

Fix. Wrap the row in AccordionHeader and put the control in AccordionActions beside the trigger. AccordionActions does nothing without an AccordionHeader around it.

AccordionToggleAll renders nothing and a console warning appears.

Cause. It requires type="multiple" — a single accordion cannot hold every section open.

Fix. Switch the root to type="multiple", or drop the control.

A per-trigger indicator prop is ignored, and the default chevron still shows.

Cause. Inside an AccordionHeader the ROW renders the caret (after the actions), not the button — so the trigger's own indicator props don't apply there.

Fix. Set indicator / hideIndicator / disableIndicatorRotation on the Accordion root when using the header layout. The per-trigger props are for the header-less usage.

A form inside a panel loses what was typed after closing it, or browser find-in-page can't see panel text.

Cause. mount="active" (the default) is lazy — a never-opened panel's children are absent from the DOM, and a closed one is unmounted.

Fix. Set keepMounted on that AccordionContent to keep its subtree alive, or mount="eager" on the root to mount every panel up front.

AccordionProgress shows 0 complete even though sections are done.

Cause. It derives completion from item status — it counts sections whose tone is "success". There is no value to pass in.

Fix. Set tone="success" on each completed AccordionItem. (It also renders nothing until at least one section has registered.)

maxOpen seems to be ignored by the expand-all button or the * key.

Cause. Both bypass the cap deliberately — an explicit "show me everything" should not be silently trimmed.

Fix. Nothing to fix. maxOpen governs opening sections one at a time; it also never evicts a section that contains focus.

Heading levels come out wrong (an h3 under an h4, or duplicated levels in a nested accordion).

Cause. headingLevel was set on the nested accordion as well as the host — nesting already steps the level down by one.

Fix. Set headingLevel once, on the outermost accordion, to match the surrounding outline. Levels clamp to h2–h6.

The restored open set is ignored, or persistKey appears to do nothing.

Cause. A controlled value always wins over the sessionStorage restore — the restore only applies to an uncontrolled accordion.

Fix. Use defaultValue (or no value at all) with persistKey. If you need both, read the stored set yourself and seed your controlled state from it.

<Accordion tone="danger"> is a type error, or setting a tone on the root colours nothing.

Cause. Colour intent here is per-section, so tone lives on AccordionItem. The root's axes are appearance, size and indicatorPosition only.

Fix. Move tone onto the AccordionItem(s) that carry the status. It is what AccordionProgress counts (tone="success"), and each non-neutral tone renders its own glyph and hidden status text.

A custom hover or selected fill on the row shows the page through it, instead of tinting the surface.

Cause. The shared state-layer tokens are translucent, so as a background-color they REPLACE an opaque fill (like filled's muted block) rather than layering over it.

Fix. Over an opaque surface, darken it with an opaque mix of that surface instead of assigning a translucent state token — or put the translucent layer on the row above the opaque item, which is how the built-in hover works.

Accessibility

Role
button
ARIA APG
accordion
Focus
The trigger carries an INSET focus ring, so a rounded `contained` / `separated` edge never clips it. Nothing in the accordion moves on press — the row tints, it does not shift. Under reduced motion the panel neither animates its height nor smooth-scrolls.

Accessibility requirements

error

Each trigger is contained in a heading element; `headingLevel={2}` renders `<h2>` and the default renders `<h3>`. The heading wraps the button alone.

when always

warn

The nested accordion renders its headings one level deeper than its host without the consumer setting `headingLevel` twice.

when an accordion is rendered inside another accordion's panel

error

Content in `AccordionActions` is not part of the trigger's accessible name; activating a control there does not toggle the panel, and the control is reachable by Tab.

when `AccordionActions` is composed inside an `AccordionHeader`

warn

The redundant caret toggle is `aria-hidden` and out of the tab order — a pointer affordance only, so it adds no extra focus stop and no duplicate announcement.

when the header layout renders the caret beside the trigger

error

Clicking the caret leaves focus on the real trigger, never on the `aria-hidden` caret — so the Arrow / Home / End / `*` keys still work immediately after a pointer click.

when the header layout renders the caret beside the trigger

warn

The caret beside a disabled trigger takes no pointer events and does not tint the row — a disabled section offers no affordance that looks live.

when a disabled section is rendered in the header layout

error

ArrowDown/ArrowUp/Home/End move focus between triggers; disabled triggers are skipped.

when always

warn

`*` on a focused trigger expands every section.

when `type="multiple"`

error

The indicator is decorative (`aria-hidden`); replacing it via `indicator` or removing it via `hideIndicator` does not change the trigger's accessible name.

when always

error

The item reflects `data-tone` and exposes its status in text (a visually-hidden label beside the glyph), not by colour alone.

when an item sets a non-neutral `tone`

warn

The item renders a Spinner in place of its indicator, marks its panel `aria-busy`, and keeps its trigger's accessible name unchanged.

when an item sets `loading`

warn

A focusable element inside a settled-open panel is not clipped by the panel's overflow.

when a panel has finished opening

warn

Opening a panel neither animates its height nor smooth-scrolls; `scrollOnOpen` jumps instead.

when `prefers-reduced-motion: reduce`

warn

`AccordionToggleAll` requires `type="multiple"`; under a single accordion it renders nothing and warns in development.

when `AccordionToggleAll` is rendered

warn

It expands every non-disabled section, flips its label to the collapse copy, collapses them all on a second activation, and announces each outcome.

when `AccordionToggleAll` is activated

warn

Opening past the cap closes the least-recently-opened section, announces which section closed, and never closes a section that contains focus.

when `maxOpen` is set under `type="multiple"`

warn

Its `label` is the progressbar's accessible name, and the value is derived from item status rather than passed in — so the read-out cannot drift from the sections.

when `AccordionProgress` is rendered

warn

The skeleton is `aria-hidden`; the loading state must be announced by the surrounding region's own status text.

when `AccordionSkeleton` is rendered

warn

The indicator renders on the inline-start (right) edge, and the panel body's indent mirrors with it — logical properties only, no physical `left`/`right` utilities.

when `dir="rtl"` with `indicatorPosition="start"`

Keyboard

EnterSpaceToggle the focused section.
ArrowDownArrowUpMove focus between section triggers, skipping disabled ones.
HomeEndMove focus to the first / last section trigger.
*Expand every section (`type="multiple"` only) — the APG shortcut, and it deliberately bypasses `maxOpen`.
TabMove between the trigger, any controls in `AccordionActions`, and into an open panel. The redundant caret button in the header layout is not a tab stop.
A1.3.1Info and RelationshipsA1.4.1Use of ColorA2.1.1KeyboardAAA2.3.3Animation from InteractionsA4.1.2Name, Role, ValueAA4.1.3Status Messages
  • Every trigger is a `<button>` inside a real heading element (h2–h6 per `headingLevel`), wired to its panel with `aria-expanded` + `aria-controls`. The heading wraps the button ALONE — actions and the caret sit outside it — so the heading's accessible name is the label, and nothing interactive is nested inside a heading.
  • A nested accordion reads its host's level from context and renders one deeper, so the outline stays valid without the consumer setting `headingLevel` twice.
  • `AccordionActions` content sits beside the button, never inside it: it stays out of the trigger's accessible name, is reachable by Tab, and using it does not toggle the panel.
  • In the header layout the caret is wrapped in a redundant pointer-only toggle (`aria-hidden`, `tabIndex={-1}`) that forwards its click to the real trigger — so it adds no second tab stop and no duplicate announcement. The labelled trigger stays the single keyboard / assistive-tech control.
  • Clicking that caret does not leave focus on it: pressing a button would normally focus it, which would park focus on a hidden element and take the arrow keys with it, so the caret refuses the focus and hands it to the trigger. A pointer click on the caret leaves you exactly where a click on the label would.
  • A section's `tone` is never colour alone: each non-neutral tone renders a status glyph plus visually-hidden text ("Warning: ", "Error: ") ahead of the label.
  • A `loading` section marks its panel `aria-busy` and swaps a Spinner for the caret without changing the trigger's accessible name.
  • `maxOpen` evictions and expand-all / collapse-all are announced through a polite live region; the eviction names the section it closed, and never closes a section that contains focus.
  • `AccordionSkeleton` is `aria-hidden` — pair it with the loading region's own status text rather than relying on it to announce anything.
  • An open panel releases its overflow clip once the height animation settles, so a focus ring or a popover inside the panel is not cut off.