Accordion
Expandable sections in a connected list or separated cards — five sizes, three variants, trigger slots for icon/description/meta, contained or plain indicators, on-color tone, heading-level control and full keyboard support.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
AccordionRoot,
AccordionItem,
AccordionHeader,
AccordionTrigger,
AccordionPanel,
} from "@tessinaui/ui";<AccordionRoot>
<AccordionItem value="item-1">
<AccordionHeader>
<AccordionTrigger>What is Tessina UI?</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>
A code-first design system with React, TypeScript, and Tailwind CSS.
</AccordionPanel>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionHeader>
<AccordionTrigger>Does it support dark mode?</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>
Yes — all colours use CSS custom properties mapped to semantic tokens.
</AccordionPanel>
</AccordionItem>
</AccordionRoot>When to use — and when not to
Use an accordion to let people scan section titles and open only what they need. Don't use one for content everyone must read (critical instructions, required legal text), to split a sequence of questions people must answer in order, or nested more than two levels deep — all three measurably hurt findability (GOV.UK research, Primer).
Boundary: a single inline "Show more / See details" disclosure is the Collapsible component's job; the Accordion is the multi-section list. Search-over-sections and sticky group headers are compositions, not props.
Examples
Default
A single-open accordion in the default ghost variant.
Sizes
Five sizes scale the trigger type, paddings, indicator optics and panel type together.
Outline
Set variant="outline" to wrap the items in a single bordered container — a
connected list with flush dividers, rounded to match rounded.
Filled
variant="filled" tints every row for a denser, card-like list.
Separated
separated detaches the items into individual cards with a per-size gap —
the variant chrome and rounded move onto each item and the dividers drop.
Works with outline (bordered cards) and filled (tinted cards).
Trigger slots
leadingIcon, description and trailingText compose the richer rows real
products use — settings rows, benefit lists, filter groups. The accessible
name stays the title alone; description and meta reach the button via
aria-describedby. Keep descriptions short — long button text is worse for
screen-reader users than for sighted ones.
Numbered
Ordinal or step numbers ride the leadingIcon slot, so they stay out of the
accessible name.
Price breakdown
The checkout expander: the collapsed row still shows the total via
trailingText; the panel itemizes it.
Indicator
Switch the trigger indicator with indicator — "chevron" (default,
rotates), "plus" (a + / − toggle), or "none". indicatorStyle="plain"
drops the tinted circle for the bare glyph — the dominant treatment in the
wild; "contained" (default) keeps the house circle. Move it to the other
edge with iconSide="start" — which is an accessibility aid, not just style:
it puts the state indicator next to the title for low-vision users (Carbon).
Don't alternate sides within one page.
Content lengths
The connected list absorbs short, medium and long panel content without the trigger rows shifting.
Multiple
Add multiple to let more than one item stay open at once.
Controlled — show / hide all
The controlled API (value + onValueChange) builds GOV.UK's researched
"Show all sections" control: one toggle whose label flips with state.
Nested
An inner accordion is ordinary panel content. Step the heading levels with
the outline (level={3} outside, level={4} inside) and stop at two levels —
deeper nesting makes content hard to find.
Interactive panel content
Panels are free-form: controls inside them join the normal Tab order after their trigger.
On color
On a coloured plate, tone="on-color" derives ink, dividers, hover washes
and the indicator chip from the surface ink — no per-variant re-theming.
Hidden until found
hiddenUntilFound keeps closed panels in the DOM behind
hidden="until-found": the browser's find-in-page can match text inside a
closed panel and auto-expand it, and search engines index the content. It
overrides keepMounted when both are set.
Disabled Item
Disable an individual item with the disabled prop on AccordionItem —
disabling never force-collapses an already-open item.
Skeleton
AccordionSkeleton renders the real root/item/trigger frame classes, so it
tracks size, variant, rounded, separated, indicatorStyle and
iconSide. Pass labels — the character counts of the real titles — so each
bar measures what its title will measure.
API Reference
AccordionRoot props
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Scales text, icon, and spacing |
variant | "ghost" | "outline" | "filled" | "ghost" | Visual style — ghost is a divider list, outline a bordered container, filled tints each row |
rounded | "none" | "sm" | "md" | "lg" | "full" | "lg" | Corner radius (panel map — full caps at rounded-3xl). Rounds the outline/filled container, each card when separated, or the ghost trigger |
separated | boolean | false | Detach items into individual cards with a per-size gap; chrome and radius move onto each item, dividers drop |
indicator | "chevron" | "plus" | "none" | "chevron" | Trigger indicator icon |
indicatorStyle | "contained" | "plain" | "contained" | Tinted circle around the glyph, or the bare glyph |
iconSide | "start" | "end" | "end" | Which (logical) edge the indicator sits on — start aids low-vision users |
tone | "default" | "on-color" | "default" | on-color derives ink/borders/washes from the surface ink for coloured plates |
panelRole | "region" | "none" | "region" | ARIA role on panels. Set "none" past ~6 panels (APG landmark guidance) |
orientation | "vertical" | "horizontal" | "vertical" | Layout axis. Horizontal is experimental — Base UI ≥1.4 deprecates accordion orientation |
dir | "ltr" | "rtl" | inherited | Text direction. Omitted, the accordion inherits from <html dir>; set, it lands as the DOM attribute |
value | AccordionValue | — | Controlled array of open item values |
defaultValue | AccordionValue | — | Uncontrolled initial open values |
onValueChange | (value: AccordionValue) => void | — | Called when open items change |
multiple | boolean | false | Allow multiple items open at once. Single mode always allows collapsing the last open item |
disabled | boolean | false | Disables all items |
loopFocus | boolean | — | Deprecated — no effect. Base UI no longer binds arrow keys on accordion triggers (the APG contract is Enter/Space/Tab); still accepted so existing code type-checks |
hiddenUntilFound | boolean | false | Uses hidden="until-found" so closed panels are discoverable by browser search. Overrides keepMounted |
keepMounted | boolean | false | Keep panels in the DOM (behind hidden) when closed |
AccordionItem props
| Prop | Type | Description |
|---|---|---|
value | any | Unique identifier for this item (auto-generated if omitted) |
disabled | boolean | Disables this item only — never force-collapses it |
onOpenChange | (open: boolean) => void | Called when this item opens or closes |
AccordionHeader props
| Prop | Type | Default | Description |
|---|---|---|---|
level | 2 | 3 | 4 | 5 | 6 | 3 | Heading element rendered (h2–h6) so the accordion fits the page outline |
AccordionTrigger props
| Prop | Type | Description |
|---|---|---|
leadingIcon | ReactNode | Leading media (icon, ordinal, small avatar). Decorative — kept out of the accessible name |
description | ReactNode | Supporting line under the title, visible collapsed. Announced via aria-describedby, never in the name. Wraps; never silently truncated |
trailingText | ReactNode | Trailing meta before the indicator (value, count, price). Also aria-describedby |
Renders a <button>; also accepts all standard button HTML attributes.
AccordionPanel props
| Prop | Type | Description |
|---|---|---|
hiddenUntilFound | boolean | Override hiddenUntilFound on this panel specifically |
keepMounted | boolean | Override keepMounted on this panel specifically |
Animates open/close via a CSS grid-template-rows transition (no
layout-thrashing height calculations), disabled under
prefers-reduced-motion.
AccordionSkeleton props
| Prop | Type | Default | Description |
|---|---|---|---|
size / variant / rounded / iconSide / indicatorStyle / separated | as Root | as Root | The same box as the real component |
labels | number[] | [18, 26, 20, 23] | Character counts of the real titles — bars are char-sized via skeletonTextWidth |
items | number | 3 | Row count; labels cycle when it exceeds them |
Accessibility
Pattern: WAI-ARIA APG Accordion.
ARIA map (what actually renders — pinned by the contract tests):
| Element | Attributes |
|---|---|
| Header | <h2>–<h6> per level (default h3), wrapping the trigger |
| Trigger | <button> with aria-expanded; aria-controls while its panel is open. With slots: aria-labelledby → title, aria-describedby → description + meta |
| Panel | role="region" + aria-labelledby → trigger. Set panelRole="none" past ~6 panels to avoid landmark proliferation (APG) |
Keyboard: Enter / Space toggle the focused header; Tab / Shift+Tab move through the page order — the APG accordion contract. Arrow keys, Home and End are not bound.
AT matrix: VoiceOver verified locally (smoke: open, operate, close,
listen). NVDA and JAWS: UNTESTED — no Windows host available; the status
badge stays beta until a pass is recorded.
Notes: triggers are real buttons, never link-styled text (GOV.UK's "mistaken for links" research fix). Trigger titles wrap rather than truncate — an ellipsis would hide the only text explaining the row. Panels keep their content in the normal Tab order.