Skip to Content

Accordion

A headless, accessible disclosure primitive. Renders semantic ul > li > h3 > button markup with the full WAI-ARIA accordion pattern wired up; aria-expanded, aria-controls, panel region, and Arrow / Home / End keyboard navigation between triggers. Open state is exposed via data-state on every part so you can drive transitions purely from CSS.

  • Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  • Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Curabitur pretium tincidunt lacus, nulla gravida orci a odio nullam varius.
  • Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt.
  • At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.
import { Accordion } from "@togetheragency/ui/accordion"; const faqItems = [ { value: "headless", title: "Lorem ipsum dolor sit amet?", content: "…" }, { value: "a11y", title: "Duis aute irure dolor?", content: "…" }, { value: "controlled", title: "Nulla facilisi morbi tempus?", content: "…" }, ]; export function BasicAccordion() { return ( <Accordion.Root type="single" defaultValue="headless" className="divide-y rounded-2xl border bg-white" > {faqItems.map((item) => ( <Accordion.Item key={item.value} value={item.value}> <Accordion.Heading> <Accordion.Trigger className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left text-sm font-medium hover:bg-neutral-50"> {item.title} <Accordion.Indicator className="text-neutral-500 data-[state=open]:-rotate-180"> <ChevronDown className="size-4" /> </Accordion.Indicator> </Accordion.Trigger> </Accordion.Heading> <Accordion.Content className="px-5 pb-4 text-sm leading-relaxed text-neutral-600"> {item.content} </Accordion.Content> </Accordion.Item> ))} </Accordion.Root> ); }

Import

import { Accordion } from "@togetheragency/ui/accordion";

The component is exported as a compound object; every sub-component is a property of Accordion:

Accordion.Root; Accordion.Item; Accordion.Heading; Accordion.Trigger; Accordion.Content; Accordion.Indicator;

Composition

Accordion follows the WAI-ARIA pattern strictly. The minimum viable accordion nests five parts — Root → Item → Heading → Trigger, plus Content as a sibling of Heading:

<Accordion.Root type="single" defaultValue="one" collapsible> <Accordion.Item value="one"> <Accordion.Heading> <Accordion.Trigger> Title <Accordion.Indicator> <ChevronDown /> </Accordion.Indicator> </Accordion.Trigger> </Accordion.Heading> <Accordion.Content>Panel content</Accordion.Content> </Accordion.Item> </Accordion.Root>
  • Accordion.Root renders a <ul> and owns the open-state store. Pass type="single" or type="multiple" to choose between exclusive and multi-open behavior.
  • Accordion.Item renders an <li>. Each item needs a unique value prop — that string is what defaultValue / value / onValueChange reference.
  • Accordion.Heading renders an <h3> by default (configurable via level). This is required by the ARIA accordion pattern — the trigger must live inside a heading so screen readers expose the structure correctly.
  • Accordion.Trigger is the real <button>. Already wired with aria-expanded, aria-controls, and keyboard navigation (ArrowDown / ArrowUp / Home / End move between triggers).
  • Accordion.Content is the <section role="region"> labelled by the trigger. The default styles animate grid-template-rows from 0fr to 1fr, giving you a smooth height transition without measuring anything.
  • Accordion.Indicator is an optional decorative <span> that mirrors the item’s data-state. It applies no transform by default — drive transitions yourself via data-[state=open]:… classes, or pass a function as children ({({ open }) => …}) to render different content per state (great for plus/minus pairs).

Open state lives in a ref + per-key listener map inside Accordion.Root. Toggling an item only re-renders the affected Accordion.Item; sibling items and the root itself don’t re-render. This keeps large FAQs fast even with rich content inside each panel.

Usage

Basic usage

A single accordion where one panel is open at a time. defaultValue puts the component in uncontrolled mode; perfect when you don’t need to read or override the open state from outside.

<Accordion.Root type="single" defaultValue="one" collapsible> <Accordion.Item value="one">…</Accordion.Item> <Accordion.Item value="two">…</Accordion.Item> </Accordion.Root>

Multiple open items

Pass type="multiple" to allow several panels open simultaneously. The value shape becomes string[] instead of string | null.

  • Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  • Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Curabitur pretium tincidunt lacus, nulla gravida orci a odio nullam varius.
  • Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt.
  • At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.
<Accordion.Root type="multiple" defaultValue={["a11y", "animation"]}> {faqItems.map((item) => ( <Accordion.Item key={item.value} value={item.value}> <Accordion.Heading> <Accordion.Trigger> {item.title} <Accordion.Indicator> <ChevronDown /> </Accordion.Indicator> </Accordion.Trigger> </Accordion.Heading> <Accordion.Content>{item.content}</Accordion.Content> </Accordion.Item> ))} </Accordion.Root>

Controlled

Pass value + onValueChange to take over the open state. The signatures mirror the type prop — string | null for "single", string[] for "multiple".

Open:
  • Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  • Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Curabitur pretium tincidunt lacus, nulla gravida orci a odio nullam varius.
  • Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt.
  • At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.
const [value, setValue] = useState<string | null>("headless"); <Accordion.Root type="single" value={value} onValueChange={setValue}> {faqItems.map((item) => ( <Accordion.Item key={item.value} value={item.value}>…</Accordion.Item> ))} </Accordion.Root>;

Non-collapsible single

With type="single", set collapsible={false} to require that one item remain open at all times. Clicking the open trigger again is a no-op.

  • Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  • Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Curabitur pretium tincidunt lacus, nulla gravida orci a odio nullam varius.
  • Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt.
  • At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.
<Accordion.Root type="single" defaultValue="headless" collapsible={false}> {faqItems.map((item) => ( <Accordion.Item key={item.value} value={item.value}>…</Accordion.Item> ))} </Accordion.Root>

Disabled items

Pass disabled on Accordion.Root to disable every item, or on an individual Accordion.Item to disable just that one. Disabled triggers are skipped during keyboard navigation, and the item exposes data-disabled for styling.

  • Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  • Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Curabitur pretium tincidunt lacus, nulla gravida orci a odio nullam varius.
  • Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt.
  • At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.
<Accordion.Root type="single" defaultValue="headless"> {faqItems.map((item, i) => ( <Accordion.Item key={item.value} value={item.value} disabled={i === 1} className="data-[disabled]:opacity-50" > </Accordion.Item> ))} </Accordion.Root>

Plus / minus icons

For indicators that need to change content between states (not just transform), pass a function as children. The render prop receives the item’s open state, so you can return a different icon for each.

  • Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  • Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Curabitur pretium tincidunt lacus, nulla gravida orci a odio nullam varius.
  • Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt.
  • At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.
<Accordion.Indicator className="grid size-6 place-items-center rounded-full bg-foreground/5 text-muted-foreground"> {({ open }) => open ? <Minus className="size-3.5" /> : <Plus className="size-3.5" /> } </Accordion.Indicator>

Without an indicator

Accordion.Indicator is fully optional. Omit it entirely when the trigger content already communicates the open state — e.g. when you’re styling the trigger label itself via data-[state=open]:… or relying solely on the panel slide.

  • Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  • Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Curabitur pretium tincidunt lacus, nulla gravida orci a odio nullam varius.
  • Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt.
  • At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.
<Accordion.Trigger className="flex w-full items-center justify-between gap-4 px-5 py-4"> <span>{item.title}</span> <span className="text-xs uppercase tracking-wide text-muted-foreground"> {item.value} </span> </Accordion.Trigger>

Animating the panel

The default Accordion.Content animates open/close via a grid-template-rows: 0fr → 1fr transition — no JS measurement, no layout thrash. Every part also exposes data-state="open" | "closed", so you can add your own transitions on the trigger, the indicator, or any nested element. Accordion.Indicator ships a transition: transform base so any transform you add animates smoothly without extra classes.

<Accordion.Indicator className="data-[state=open]:-rotate-180"> <ChevronDown /> </Accordion.Indicator>

No transform is applied by default — rotation, scaling, or any other open state styling is opt-in via data-[state=open]:… classes on the indicator.

Examples

Multi-open card stack

type="multiple" paired with per-item cards instead of a divided list — the ring color picks up the open state via data-[state=open].

  • Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  • Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Curabitur pretium tincidunt lacus, nulla gravida orci a odio nullam varius.
  • Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt.
  • At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.
<Accordion.Root type="multiple" defaultValue={["a11y", "animation"]} className="flex flex-col gap-2"> {faqItems.map((item) => ( <Accordion.Item key={item.value} value={item.value} className="overflow-hidden rounded-xl bg-white ring-1 ring-neutral-200 data-[state=open]:ring-neutral-400" > <Accordion.Heading> <Accordion.Trigger className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left text-sm font-medium"> {item.title} <Accordion.Indicator className="grid size-6 place-items-center rounded-full bg-neutral-100 data-[state=open]:-rotate-180"> <ChevronDown className="size-3.5" /> </Accordion.Indicator> </Accordion.Trigger> </Accordion.Heading> <Accordion.Content className="border-t px-4 py-3 text-sm leading-relaxed text-neutral-600"> {item.content} </Accordion.Content> </Accordion.Item> ))} </Accordion.Root>

Controlled with external toggles

Open state lifted to a parent — external pill buttons can drive the accordion the same way the triggers do.

Open:
  • Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  • Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Curabitur pretium tincidunt lacus, nulla gravida orci a odio nullam varius.
  • Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt.
  • At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.
const [value, setValue] = useState<string | null>("headless"); return ( <> <div className="flex gap-2"> {faqItems.map((item) => ( <button key={item.value} onClick={() => setValue((cur) => (cur === item.value ? null : item.value)) } data-active={value === item.value || undefined} className="rounded-full border px-3 py-1 data-[active]:bg-neutral-900 data-[active]:text-white" > {item.value} </button> ))} </div> <Accordion.Root type="single" value={value} onValueChange={setValue}> {faqItems.map((item) => ( <Accordion.Item key={item.value} value={item.value}>…</Accordion.Item> ))} </Accordion.Root> </> );

API Reference

Accordion.Root

Renders a <ul> and owns the open-state store. Extends all native ul props except onChange.

PropTypeDefaultDescription
type"single" | "multiple""single"Whether one or many items can be open at a time. Changes the shape of value / defaultValue / onValueChange.
valuestring | null (single) · string[] (multiple)undefinedControlled open state. Pair with onValueChange. Switch to uncontrolled by omitting this prop.
defaultValuestring | null (single) · string[] (multiple)undefinedInitial open state in uncontrolled mode.
onValueChange(value: string | null) => void (single) · (value: string[]) => void (multiple)undefinedFires whenever an item is toggled.
collapsiblebooleantrueWhen type="single", allows closing the currently open item. Ignored when type="multiple".
disabledbooleanfalseDisables interaction with every item.
classNamestringMerged via cn — your classes can replace the default list-none.
...restOmit<React.ComponentPropsWithoutRef<"ul">, "onChange">Forwarded to the underlying <ul>.

Data attributes: data-slot="accordion-root", data-orientation="vertical".

Accordion.Item

Wraps a single accordion entry. Renders an <li> and provides item context (open state, generated IDs) to its descendants.

PropTypeDefaultDescription
valuestringrequiredUnique identifier — referenced by defaultValue / value / onValueChange on the root.
disabledbooleanfalseDisables this item only. Skipped during keyboard navigation.
classNamestringMerged via cn.
...restReact.ComponentPropsWithoutRef<"li">Forwarded to the underlying <li>.

Data attributes: data-slot="accordion-item", data-state="open" | "closed", data-disabled (empty string when disabled, otherwise absent).

Accordion.Heading

The heading element that wraps the trigger — required by the ARIA accordion pattern.

PropTypeDefaultDescription
level1 | 2 | 3 | 4 | 5 | 63Heading level. Renders the corresponding <hN> tag.
classNamestringMerged via cn. Default is m-0 so the heading doesn’t introduce extra spacing.
...restReact.ComponentPropsWithoutRef<"h3">Forwarded to the underlying heading element.

Data attributes: data-slot="accordion-heading", data-state="open" | "closed".

Accordion.Trigger

The interactive <button> that toggles the item. Wired with aria-expanded, aria-controls, and keyboard navigation (ArrowDown / ArrowUp / Home / End).

PropTypeDefaultDescription
type"button" | "submit" | "reset""button"Standard button type.
disabledbooleanundefinedForce-disable just this trigger. Overrides item/root disabled.
onClickReact.MouseEventHandler<HTMLButtonElement>undefinedFires before the toggle. Call event.preventDefault() to skip the built-in toggle.
onKeyDownReact.KeyboardEventHandler<HTMLButtonElement>undefinedFires before the built-in arrow-key handling. Call event.preventDefault() to skip navigation.
classNamestringMerged via cn — defaults include cursor and transition utilities you can override.
...restReact.ComponentPropsWithoutRef<"button">Forwarded to the underlying <button>.

Built-in attributes: aria-expanded, aria-controls, data-slot="accordion-trigger", data-state="open" | "closed", data-disabled (empty string when disabled).

Accordion.Content

The expandable panel. Renders a <section role="region"> labelled by its trigger via aria-labelledby. Uses inert when closed so its contents aren’t focusable or read by assistive tech.

PropTypeDefaultDescription
classNamestringApplied to the inner content <div>. Merged via cn — defaults include min-h-0 overflow-hidden.
...restReact.ComponentPropsWithoutRef<"div">Forwarded to the inner content <div> (not the outer <section> — the outer element owns the animation).

Data attributes: data-slot="accordion-content", data-state="open" | "closed".

Animation: a grid-template-rows: 0fr ↔ 1fr transition is applied by default, with motion-reduce:transition-none honoring the user’s preference.

Accordion.Indicator

Decorative <span> rendered inside the trigger. Mirrors data-state and ships a transition: transform base so any open-state transform you add via className animates smoothly. No transform is applied by default — drive rotation, scale, etc. yourself with data-[state=open]:… classes.

PropTypeDefaultDescription
classNamestringMerged via cn. Add data-[state=open]:… utilities here to drive transitions on open/close.
childrenReact.ReactNode | (({ open }: { open: boolean }) => React.ReactNode)Either a static node (typical chevron usage) or a function returning a node per state — pass a function to swap icons on open/close (e.g. plus → minus).
...restReact.ComponentPropsWithoutRef<"span">Forwarded to the underlying <span>.

aria-hidden="true" is always set so the indicator never announces to assistive tech. Data attributes: data-slot="accordion-indicator", data-state="open" | "closed".

Keyboard interactions

KeyAction
Space / EnterToggle the focused trigger.
ArrowDownMove focus to the next trigger (wraps to the first).
ArrowUpMove focus to the previous trigger (wraps to the last).
HomeMove focus to the first trigger.
EndMove focus to the last trigger.
Tab / Shift+TabStandard tab navigation — moves through triggers and the visible content of open panels.

Disabled triggers are skipped during arrow / Home / End navigation.

Exported types

import type { AccordionContentProps, AccordionHeadingProps, AccordionIndicatorProps, AccordionItemProps, AccordionRootProps, AccordionTriggerProps, AccordionType, // "single" | "multiple" } from "@togetheragency/ui/accordion";
Last updated on