A vertically stacked set of collapsible sections — hairline rows with a chevron that flips open→closed (WAI-ARIA Accordion pattern).
Playground
Preview
import { Accordion, AccordionItem, AccordionHeader, AccordionTrigger, AccordionTriggerIcon, AccordionContent } from "@/components/ui/accordion";import { ChevronDown } from "@primitiv-ui/icons";
<Accordion size="md" defaultValue="install"> <AccordionItem value="install"> <AccordionHeader> <AccordionTrigger> How do I install a component? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem> <AccordionItem value="own"> <AccordionHeader> <AccordionTrigger> What does owning the code mean? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem> <AccordionItem value="headless"> <AccordionHeader> <AccordionTrigger> Can I use the behaviour without the styles? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem></Accordion>import { Accordion } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";
<Accordion.Root defaultValue="install"> <Accordion.Item value="install"> <Accordion.Header> <Accordion.Trigger> How do I install a component? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item> <Accordion.Item value="own"> <Accordion.Header> <Accordion.Trigger> What does owning the code mean? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item> <Accordion.Item value="headless"> <Accordion.Header> <Accordion.Trigger> Can I use the behaviour without the styles? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item></Accordion.Root>Density is set by a data-density ancestor — the Context system, not a Accordion prop.
Installation
npx primitiv add accordionpnpm dlx primitiv add accordionyarn dlx primitiv add accordionbunx primitiv add accordionImport
import { Accordion } from "@/components/ui/accordion";Copied into your project as .primitiv-accordion — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
<Accordion> <AccordionItem> <AccordionHeader> <AccordionTrigger> <AccordionTriggerIcon /> </AccordionTrigger> </AccordionHeader> <AccordionContent /> </AccordionItem></Accordion><Accordion.Root> <Accordion.Item> <Accordion.Header> <Accordion.Trigger> <Accordion.TriggerIcon /> </Accordion.Trigger> </Accordion.Header> <Accordion.Content /> </Accordion.Item></Accordion.Root>Props
Accordion.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| defaultValue | string | — | headless | Forbidden in controlled mode — use value.
Value of the item expanded on first render. A single value even in
multiple mode — uncontrolled accordions can seed only one open item;
pass controlled AccordionRootControlledProps.value`value` to
start with several. Omit to start with everything collapsed. |
| dir | "ltr" | "rtl" | — | headless | Reading direction; see AccordionReadingDirection. In "rtl" the
horizontal arrow keys are mirrored. Also set as the container's dir
attribute. Inherited from the nearest DirectionProvider when
omitted, falling back to "ltr". |
| multiple | boolean | false | headless | Allow more than one section to be expanded at a time. When false,
opening an item collapses whichever item was previously open. |
| onValueChange | (values: string[]) => void | — | headless | Called with the complete next array of expanded values whenever the user toggles an item. Forbidden in uncontrolled mode. |
| orientation | "vertical" | "horizontal" | "vertical" | headless | Layout axis, controlling arrow-key navigation: "vertical" binds
ArrowUp/ArrowDown, "horizontal" binds ArrowLeft/ArrowRight. Surfaces as
data-orientation on the root. |
| value | string[] | — | headless | The full set of currently expanded item values. Must be kept in sync by
the parent via onValueChange. In single (multiple={false}) mode this
holds at most one value.
Forbidden in uncontrolled mode — use defaultValue. |
| size | "xs" | "sm" | "md" | "lg" | "xl" | md | styled | Control size for the whole widget; data-density scales each size further. |
Accordion.Item
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | Section contents — typically an AccordionHeaderProps`Accordion.Header`
+ AccordionContentProps`Accordion.Content` pair. |
| value | string | — | headless | Stable identifier matched against the root's expanded set (and against
value / defaultValue). When omitted, a stable id is generated via
useId() — fine for anonymous items whose state is never driven from
outside. |
Accordion.Header
Extends HTMLHeadingElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | Header contents — typically an
AccordionTriggerProps`Accordion.Trigger`. |
| level | HeadingLevel | 3 | headless | Heading level to render (h1–h6). Choose the level that fits the
surrounding document outline — the WAI-ARIA Accordion pattern requires
each trigger to be wrapped in a heading at the right level. |
Accordion.Trigger
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | Trigger label / contents — the visible section heading text, plus an
optional AccordionTriggerIconProps`Accordion.TriggerIcon`. |
| asChild | boolean | false | headless | Render the consumer's own element instead of a <button>, merging all
accordion ARIA attributes, event handlers, and the ref onto it via the
Slot pattern. When combined with disabled, role="button" is
injected so aria-disabled is valid on non-button children. |
| disabled | boolean | false | headless | Disable the trigger. Rendered as aria-disabled / data-disabled
rather than the native disabled attribute, so the button stays
focusable (discoverable by keyboard) but is excluded from arrow-key
navigation and cannot be activated. |
| ref | Ref<T> | — | headless | Ref to the rendered element. Defaults to HTMLButtonElement; when using
asChild, specify the child's element type (e.g. HTMLAnchorElement). |
Accordion.Content
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | Panel contents, revealed when the matching trigger is expanded. |
| forceMount | boolean | false | headless | Keep the panel mounted in the DOM even while collapsed (instead of
removing it from view with the hidden attribute), so CSS open/close
transitions can run. While closed it receives aria-hidden="true" so
assistive tech still ignores it; drive visibility yourself via
[data-state="closed"]. |
Accordion.TriggerIcon
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | Icon contents — an inline <svg> or any icon component. Rendered inside
an aria-hidden <span> carrying a data-state open/close hook. |
Styling contract
Control frame
--primitiv-accordion-trigger-padding-inline--primitiv-accordion-trigger-gap--primitiv-accordion-trigger-icon-size--primitiv-accordion-trigger-icon-rotation--primitiv-accordion-trigger-fg--primitiv-accordion-trigger-font-family--primitiv-accordion-trigger-font-size--primitiv-accordion-trigger-font-weight--primitiv-accordion-trigger-line-heightPanel
--primitiv-accordion-content-padding-block--primitiv-accordion-content-padding-inline--primitiv-accordion-content-fg--primitiv-accordion-content-font-family--primitiv-accordion-content-font-size--primitiv-accordion-content-font-weight--primitiv-accordion-content-line-height--primitiv-accordion-content-transition-duration--primitiv-accordion-content-transition-easingItem
--primitiv-accordion-item-border-color--primitiv-accordion-item-border-widthKeyboard
| Key | Behaviour |
|---|---|
| Enter / Space | Toggle the focused section. |
| ArrowDown / ArrowUp | Move focus to the next / previous trigger, when orientation is vertical (the default). |
| ArrowRight / ArrowLeft | The same, when orientation is horizontal. Under dir="rtl" the pair is mirrored. |
| Home / End | First / last enabled trigger. |
| Tab | Leave the accordion — not move within it. |
Data attributes
Accordion
className: .primitiv-accordion
| Attribute | Value | When |
|---|---|---|
data-orientation | horizontal | vertical | horizontal / vertical |
Accordion.Root
| Attribute | Value | When |
|---|---|---|
data-orientation | horizontal | vertical | horizontal / vertical |
AccordionTrigger
className: .primitiv-accordion__trigger
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
data-disabled | true | false | disabled / not disabled |
Accordion.Trigger
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
data-disabled | true | false | disabled / not disabled |
AccordionContent
className: .primitiv-accordion__content
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
Accordion.Content
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
AccordionTriggerIcon
className: .primitiv-accordion__trigger-icon
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
Accordion.TriggerIcon
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
Accessibility
- Each trigger sits inside a real heading (
Accordion.Header,h3by default,levelto change it). That is what the WAI-ARIA Accordion pattern requires and what lets a screen-reader user list the sections and jump straight to one — a<div>wrapper would leave them scrolling. - The triggers share one tab stop. Tab moves into the accordion and then out of it; the arrows, Home and End move between sections. That is deliberate — a group of related controls behaves as one stop so a keyboard user is not made to Tab through every section to reach the content after it.
EnterandSpacetoggle, because each trigger is a real<button>witharia-expandedandaria-controlswired to its panel. Nothing here reimplements a button.disabledon a trigger is rendered asaria-disabled, not the native attribute, so the trigger stays focusable and discoverable while being skipped by the arrow keys. A disabled control that cannot be focused is one a screen-reader user never learns exists.- A closed panel is
hidden, oraria-hiddenwhen force-mounted for animation. Either way it is out of the accessibility tree, so a force-mounted panel never leaks its content to a screen reader while it looks closed. Accordion.TriggerIconis decorative — give the chevronaria-hidden. The expanded state is already announced througharia-expanded, so a labelled icon would say it twice.- Reach for
Collapsiblewhen there is only one section. An accordion of one is a disclosure with extra ARIA, and the arrow-key model it sets up has nothing to move between.
Examples
One section at a time
The default: opening a section closes whichever was open. Use it when the sections are alternatives — a set of mutually exclusive settings, or a form whose steps are taken in order. defaultValue seeds which one starts open; omit it to start fully collapsed.
import { Accordion, AccordionItem, AccordionHeader, AccordionTrigger, AccordionTriggerIcon, AccordionContent } from "@/components/ui/accordion";import { ChevronDown } from "@primitiv-ui/icons";
<Accordion defaultValue="install"> <AccordionItem value="install"> <AccordionHeader> <AccordionTrigger> How do I install a component? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem> <AccordionItem value="own"> <AccordionHeader> <AccordionTrigger> What does owning the code mean? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem> <AccordionItem value="headless"> <AccordionHeader> <AccordionTrigger> Can I use the behaviour without the styles? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem></Accordion>import { Accordion } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";
<Accordion.Root defaultValue="install"> <Accordion.Item value="install"> <Accordion.Header> <Accordion.Trigger> How do I install a component? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item> <Accordion.Item value="own"> <Accordion.Header> <Accordion.Trigger> What does owning the code mean? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item> <Accordion.Item value="headless"> <Accordion.Header> <Accordion.Trigger> Can I use the behaviour without the styles? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item></Accordion.Root>Several at once
multiple lets sections open independently, which is what an FAQ or a reference page wants — closing what someone just read to show them something else is the wrong model there. One asymmetry to know: even in multiple mode an uncontrolled accordion can only seed one open section through defaultValue. Start with several and you need the controlled form below.
import { Accordion, AccordionItem, AccordionHeader, AccordionTrigger, AccordionTriggerIcon, AccordionContent } from "@/components/ui/accordion";import { ChevronDown } from "@primitiv-ui/icons";
<Accordion multiple defaultValue="install"> <AccordionItem value="install"> <AccordionHeader> <AccordionTrigger> How do I install a component? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem> <AccordionItem value="own"> <AccordionHeader> <AccordionTrigger> What does owning the code mean? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem> <AccordionItem value="headless"> <AccordionHeader> <AccordionTrigger> Can I use the behaviour without the styles? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem></Accordion>import { Accordion } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";
<Accordion.Root multiple defaultValue="install"> <Accordion.Item value="install"> <Accordion.Header> <Accordion.Trigger> How do I install a component? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item> <Accordion.Item value="own"> <Accordion.Header> <Accordion.Trigger> What does owning the code mean? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item> <Accordion.Item value="headless"> <Accordion.Header> <Accordion.Trigger> Can I use the behaviour without the styles? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item></Accordion.Root>Controlled
Pass value and onValueChange and the parent owns the open set — needed to start with several sections open, to open one in response to something else on the page, or to persist the state. value is always an array, even in single mode where it holds at most one entry. Note the uncontrolled form deliberately does not call onValueChange, matching Tabs: if you need to observe changes, you are controlled.
Open: install
import { Accordion, AccordionItem, AccordionHeader, AccordionTrigger, AccordionTriggerIcon, AccordionContent } from "@/components/ui/accordion";import { ChevronDown } from "@primitiv-ui/icons";
const [open, setOpen] = useState<string[]>(["install"]);
<Accordion multiple value={open} onValueChange={setOpen}> <AccordionItem value="install"> <AccordionHeader> <AccordionTrigger> How do I install a component? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem> <AccordionItem value="own"> <AccordionHeader> <AccordionTrigger> What does owning the code mean? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem> <AccordionItem value="headless"> <AccordionHeader> <AccordionTrigger> Can I use the behaviour without the styles? <AccordionTriggerIcon> <ChevronDown aria-hidden="true" /> </AccordionTriggerIcon> </AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem></Accordion>import { Accordion } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";
const [open, setOpen] = useState<string[]>(["install"]);
<Accordion.Root multiple value={open} onValueChange={setOpen}> <Accordion.Item value="install"> <Accordion.Header> <Accordion.Trigger> How do I install a component? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item> <Accordion.Item value="own"> <Accordion.Header> <Accordion.Trigger> What does owning the code mean? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item> <Accordion.Item value="headless"> <Accordion.Header> <Accordion.Trigger> Can I use the behaviour without the styles? <Accordion.TriggerIcon> <ChevronDown aria-hidden="true" /> </Accordion.TriggerIcon> </Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item></Accordion.Root>Heading level
Accordion.Header renders a real heading — h3 by default — and level moves it to fit the page's outline. This is not styling: the pattern requires the trigger to sit inside a heading so assistive tech can list the sections and jump between them, and a heading at the wrong level breaks the document outline just as surely as a missing one. Pick the level that follows the heading above the accordion.
import { Accordion, AccordionItem, AccordionHeader, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";import { ChevronDown } from "@primitiv-ui/icons";
<h2>Frequently asked</h2>
<Accordion> <AccordionItem value="install"> {/* h3 by default; under an h2 that is already right. */} <AccordionHeader level={3}> <AccordionTrigger>How do I install a component?</AccordionTrigger> </AccordionHeader> <AccordionContent>{/* ... */}</AccordionContent> </AccordionItem></Accordion>import { Accordion } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";
<h2>Frequently asked</h2>
<Accordion.Root> <Accordion.Item value="install"> {/* h3 by default; under an h2 that is already right. */} <Accordion.Header level={3}> <Accordion.Trigger>How do I install a component?</Accordion.Trigger> </Accordion.Header> <Accordion.Content>{/* ... */}</Accordion.Content> </Accordion.Item></Accordion.Root>Animating the panel
The styled surface already animates: the panel is a grid whose row track moves between 0fr and 1fr, which is how a height transition runs to a height nobody has to measure. Doing it yourself needs forceMount — without it the panel unmounts when it closes and there is nothing left to transition. The panel keeps aria-hidden while closed either way, so a force-mounted panel is still invisible to assistive tech.
import { Accordion, AccordionItem, AccordionHeader, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";import { ChevronDown } from "@primitiv-ui/icons";
<AccordionContent forceMount>{/* ... */}</AccordionContent>
/* Your stylesheet — the panel publishes data-state. */.panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 200ms ease;}
.panel[data-state="open"] { grid-template-rows: 1fr; }
/* The child must be able to collapse to nothing. */.panel > * { min-block-size: 0; overflow: hidden; }import { Accordion } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";
<Accordion.Content forceMount>{/* ... */}</Accordion.Content>
/* Your stylesheet — the panel publishes data-state. */.panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 200ms ease;}
.panel[data-state="open"] { grid-template-rows: 1fr; }
/* The child must be able to collapse to nothing. */.panel > * { min-block-size: 0; overflow: hidden; }