Skip to content
Primitiv home
Framework
Consumption mode

Accordion

stableSource Figma

A vertically stacked set of collapsible sections — hairline rows with a chevron that flips open→closed (WAI-ARIA Accordion pattern).

Playground

Density

Preview

Run the CLI and the component's files are copied into your project. There is no package to add and nothing to upgrade — the code is yours from that point on.

Size
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>

Density is set by a data-density ancestor — the Context system, not a Accordion prop.

Installation

npx primitiv add accordion

Import

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>

Props

Accordion.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
defaultValuestringheadlessForbidden 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"headlessReading 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".
multiplebooleanfalseheadlessAllow more than one section to be expanded at a time. When false, opening an item collapses whichever item was previously open.
onValueChange(values: string[]) => voidheadlessCalled with the complete next array of expanded values whenever the user toggles an item. Forbidden in uncontrolled mode.
orientation"vertical" | "horizontal""vertical"headlessLayout axis, controlling arrow-key navigation: "vertical" binds ArrowUp/ArrowDown, "horizontal" binds ArrowLeft/ArrowRight. Surfaces as data-orientation on the root.
valuestring[]headlessThe 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"mdstyledControl 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.

PropTypeDefaultFromDescription
children (required)ReactNodeheadlessSection contents — typically an AccordionHeaderProps`Accordion.Header` + AccordionContentProps`Accordion.Content` pair.
valuestringheadlessStable 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.

PropTypeDefaultFromDescription
children (required)ReactNodeheadlessHeader contents — typically an AccordionTriggerProps`Accordion.Trigger`.
levelHeadingLevel3headlessHeading level to render (h1h6). 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.

PropTypeDefaultFromDescription
children (required)ReactNodeheadlessTrigger label / contents — the visible section heading text, plus an optional AccordionTriggerIconProps`Accordion.TriggerIcon`.
asChildbooleanfalseheadlessRender 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.
disabledbooleanfalseheadlessDisable 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.
refRef<T>headlessRef 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.

PropTypeDefaultFromDescription
children (required)ReactNodeheadlessPanel contents, revealed when the matching trigger is expanded.
forceMountbooleanfalseheadlessKeep 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.

PropTypeDefaultFromDescription
children (required)ReactNodeheadlessIcon 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-height

Panel

--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-easing

Item

--primitiv-accordion-item-border-color--primitiv-accordion-item-border-width

Keyboard

KeyBehaviour
Enter / SpaceToggle the focused section.
ArrowDown / ArrowUpMove focus to the next / previous trigger, when orientation is vertical (the default).
ArrowRight / ArrowLeftThe same, when orientation is horizontal. Under dir="rtl" the pair is mirrored.
Home / EndFirst / last enabled trigger.
TabLeave the accordion — not move within it.

Data attributes

Accordion

className: .primitiv-accordion

AttributeValueWhen
data-orientationhorizontal | verticalhorizontal / vertical

AccordionTrigger

className: .primitiv-accordion__trigger

AttributeValueWhen
data-stateopen | closedopen / closed
data-disabledtrue | falsedisabled / not disabled

AccordionContent

className: .primitiv-accordion__content

AttributeValueWhen
data-stateopen | closedopen / closed

AccordionTriggerIcon

className: .primitiv-accordion__trigger-icon

AttributeValueWhen
data-stateopen | closedopen / closed

Accessibility

  • Each trigger sits inside a real heading (Accordion.Header, h3 by default, level to 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.
  • Enter and Space toggle, because each trigger is a real <button> with aria-expanded and aria-controls wired to its panel. Nothing here reimplements a button.
  • disabled on a trigger is rendered as aria-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, or aria-hidden when 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.TriggerIcon is decorative — give the chevron aria-hidden. The expanded state is already announced through aria-expanded, so a labelled icon would say it twice.
  • Reach for Collapsible when 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.

Density

Run the CLI and the component's files are copied into your project. There is no package to add and nothing to upgrade — the code is yours from that point on.

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>

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.

Density

Run the CLI and the component's files are copied into your project. There is no package to add and nothing to upgrade — the code is yours from that point on.

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>

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.

Density

Open: install

Run the CLI and the component's files are copied into your project. There is no package to add and nothing to upgrade — the code is yours from that point on.

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>

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.

Density

Run the CLI and the component's files are copied into your project. There is no package to add and nothing to upgrade — the code is yours from that point on.

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>

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.

Density

Run the CLI and the component's files are copied into your project. There is no package to add and nothing to upgrade — the code is yours from that point on.

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; }