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.
pnpm add @tessinaui/uiWhen to use
- A list of related sections where people need only some. An FAQ or a settings page lets people scan the titles and open what they need.
- Long content that need not be read in full. Collapsing it shortens the page and cuts scrolling, on a phone or in a side panel above all.
- Sections people compare across. Set
multipleso several can stay open at once, as long as a handful open together is not overwhelming. - Short, specific titles. A title that fits on one line scans well and hints at what is inside.
- A familiar task for regular users. Hiding detail they already know speeds them up.
When not to use
- For content everyone must read. Critical instructions and required legal text belong on the page, not behind a click.
- For what a task needs to finish. Information required to complete the current step stays visible.
- For a single inline disclosure. A lone "Show more" or "See details" is Collapsible; the accordion is the multi-section list.
- For views that sit side by side. Peer sections people switch between are Tabs; the accordion is a vertical list only.
- To split a sequence of questions people must answer in order. Use separate steps.
- Nested more than two levels deep. Deeper nesting hurts findability; flatten the content instead.
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>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" is a card for a grouped page: the card colour in light, the raised panel tone in dark, on a page painted bg-muted dark:bg-background — the same card Card, Fieldset and Form draw for filled.
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 (grouped 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. The meta sits at the trailing edge,
centred on the title and description; when it no longer fits beside them it
wraps underneath instead of squeezing the text.
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, so a low-vision or magnified
reader sees both without crossing the row. 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 the 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. Inside a panel a ghost trigger
draws its focus ring inside the row, so the panel's edge never cuts it.
Interactive panel content
Panels are free-form: controls inside them join the normal Tab order after their trigger.
On color
Inside a Surface, Accordion inherits tone="on-color": ink, dividers, hover
washes and the indicator chip derive from the surface ink — no per-variant
re-theming. Pass tone="on-color" yourself only on a plate something else
paints; an explicit tone always wins.
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.
RTL
Set dir="rtl" and the trigger text starts at the right while the chevron moves
to the left edge, since iconSide is logical. The chevron points down and up,
so it does not flip. Leave dir unset and the accordion follows the page.
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 a grouped card |
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" | inherited | on-color derives ink/borders/washes from the surface ink for coloured plates. Inherited from a wrapping <Surface>, else "default"; an explicit prop wins |
panelRole | "region" | "none" | "region" | ARIA role on panels. Set "none" past ~6 panels (APG landmark guidance) |
orientation | "vertical" | "horizontal" | — | Deprecated — no effect. The accordion is vertical only; "horizontal" renders the vertical list. Still accepted so existing code type-checks |
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), centred on the title and description; wraps under them when it no longer fits. 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: not yet run — the smoke pass (open, operate,
close, listen) is still pending. 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 — underlined titles get mistaken for links and read as navigation. 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.