A vertically stacked set of disclosure sections, styled after the shadcn / ngrok.com accordion.
Its defining feature: collapsed content is never removed from the DOM. Closed sections keep their content with
hidden="until-found" (which applies content-visibility: hidden rather than unmounting it), so the browser's
find-in-page (⌘F / Ctrl+F) can find text inside a closed section and automatically expand it.
The beforematch event (fired right before the browser reveals a match) syncs that reveal back into the accordion's
state. It's built on plain <div> and <button> elements (matching native <details>/<summary> accessibility:
the item is a role="group" and the trigger a <button aria-expanded>), so a section header can be any layout.
type is optional and defaults to "multiple": sections open independently, so any number can be open at once
(open one without collapsing another below).
--domain flag.Set type="single" to make opening one section auto-close the previously open one, the classic accordion where at
most one section is open at a time:
--domain flag.When to use
- FAQ pages, settings panels, and docs where users scan headings and drill into one topic at a time.
- Long-form content you still want discoverable: because collapsed sections stay in the DOM, find-in-page matches text inside them and auto-expands the section.
- Dense layouts (like the dashboard's Traffic Policy GUI) where each section needs its own header controls beside the toggle.
When not to use
- Switching between mutually exclusive views of the same region: use
Tabsinstead. - A single, always-relevant block of content: don't hide it behind a toggle; render it.
- Content a user must not miss: a collapsed section is easy to overlook, so keep critical information visible.
Examples
Multiple sections open at once
type="multiple" lets any number of sections be open at once, and it's the default, so you can omit type
entirely. Pass defaultValue (uncontrolled) or value + onValueChange (controlled) as a string[].
Controlled
Drive the open state yourself with value + onValueChange when another control needs to open or collapse sections.
In type="single" the value is a string (use "" for "nothing open"); in type="multiple" it's a string[].
Open section: payments
Custom header with an inline action
The dashboard's Traffic Policy GUI puts a monospace label, a count badge, and an always-visible Add Rule action in
each header. Because the Item is a plain <div> and the Trigger is a real <button>, the header is a flex
row holding the Trigger, a divider, and the action button as siblings: a clean composition with no overlay and
no absolute positioning. The Trigger toggles its section; the Add Rule button is an independent sibling that
never toggles it; and the Content spans full-width below. Override the defaults (space-y-* and divide-y-0 on
Root to swap the dividers for spacing; and w-auto on Trigger so it sizes to its label) for the spaced-card look.
This phase does not have any rules defined
Multi-line trigger labels
When a trigger label wraps, Accordion.TriggerIcon stays centered on the first line of the label.
Custom indicator icon
Pass svg to Accordion.TriggerIcon to replace the default caret. The indicator still rotates 180° on open; override
its className to change that: here a right caret rotates 90° into a downward one for a file-tree feel.
Accessibility
The accessibility mirrors a native <details>/<summary>: each Accordion.Item is a role="group", and each
Accordion.Trigger is a real <button> whose aria-expanded reflects its section's open state and whose
aria-controls points at its Accordion.Content. Because the trigger is a genuine button, keyboard support is the
browser's own; there is no custom key handling to get wrong:
- Tab / Shift+Tab: move focus between triggers (and any other focusable controls in a header).
- Enter / Space: toggle the focused section.
Collapsed content stays in the DOM but leaves the accessibility tree and the tab order, like a closed <details>. In
browsers that support hidden="until-found", the content carries that attribute, so the browser's find-in-page can
still match and reveal it. In other browsers, and in the server HTML, the content carries inert instead. The
open/close slide respects prefers-reduced-motion.
The trigger keeps the same size when its section opens, so the header never moves. Only the content moves: its height
and opacity ease open over 200 ms with a strong ease-out (in browsers that support interpolate-size; others open at
once), and the caret turns with the same curve.
Composition
Compose the parts of an Accordion together to build your own:
Polymorphism
Accordion.Root accepts an asChild prop. When true, the root renders its single child element instead of the default <div>, forwarding its class names, data-* attributes, and ref onto that child. Reach for it when the accordion should be a semantic landmark such as a <section>.
API Reference
The Accordion owns its open/closed state and stays in sync whether a section is opened by a click, by keyboard, or by
the browser's find-in-page.
Accordion.Root
Owns the accordion state and renders a wrapping <div> (or your own element via asChild). The type prop is a
discriminated union that shapes the open value:
type="multiple"— the default. Any number of sections may be open at once, and opening one never closes another.value/defaultValueare astring[], andonValueChangereceives the new list. Omittingtypeentirely is the same astype="multiple".type="single"— at most one section open at a time: opening a section auto-closes the previously open one.value/defaultValueare astring(use""for none), andonValueChangereceives the newly open value.
Pass value + onValueChange for controlled usage, or defaultValue for uncontrolled usage. Accepts all <div>
props, plus:
Accordion.Item
A single collapsible section, rendered as a <div role="group"> — the role a native <details> carries. Its open
state is derived from Root. Because the item is a plain <div>, its header can be any layout, including a flex row
with the Trigger beside a separate, non-toggling action button. Accepts all <div> props, plus:
Accordion.Trigger
The clickable header that toggles its section, rendered as a <button aria-expanded> — the disclosure semantics a
native <summary> carries, via a real button so the role is consistent across browsers. Its aria-controls points at
the item's Accordion.Content. Defaults to full-width with
the TriggerIcon pushed to the trailing edge; set w-auto when placing it in a custom header row. A consumer onClick
runs first and the section still toggles afterward (calling preventDefault() does not stop the toggle). Accepts all
<button> props except type, which is always "button".
Accordion.TriggerIcon
The indicator icon. Defaults to a downward caret that rotates 180° when its section opens. Accepts all
Icon props (notably className and style), plus:
Accordion.Content
The collapsible region, rendered as a <div> that is always present in the DOM — the zero-padding viewport that slides
open and closed. When collapsed, it leaves the accessibility tree and the tab order: in browsers that support
hidden="until-found" it carries that attribute, so its text stays discoverable by find-in-page and a beforematch
listener opens the section when the browser reveals a match; in other browsers, and in the server HTML, it carries
inert. Its id is the target of the trigger's aria-controls; Accordion.Item generates it, so id is not a prop
here. It holds no padding of its own so its h-0 ↔ h-auto slide can collapse to zero height: put the section's content
inside an Accordion.Body. It is flow content, so it may contain anything, including interactive elements
(buttons, links, inputs) or a nested Accordion. Accepts all <div> props except id.
Accordion.Body
The padded inner region of a Content, where the section's body content lives. It owns the bottom padding so Content
can stay an unpadded animation viewport (a padded h-0 border-box can't collapse below its padding, so the closed
slide would never reach zero height). Text inherits the ambient size rather than being forced to a fixed size — set a
text class where you need one. Override className to retune the spacing — e.g. pb-0 to remove the bottom padding,
px-6 for horizontal padding — and cx resolves last-wins over the defaults. Accepts all <div> props.