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.Rootrenders a<ul>and owns the open-state store. Passtype="single"ortype="multiple"to choose between exclusive and multi-open behavior.Accordion.Itemrenders an<li>. Each item needs a uniquevalueprop — that string is whatdefaultValue/value/onValueChangereference.Accordion.Headingrenders an<h3>by default (configurable vialevel). This is required by the ARIA accordion pattern — the trigger must live inside a heading so screen readers expose the structure correctly.Accordion.Triggeris the real<button>. Already wired witharia-expanded,aria-controls, and keyboard navigation (ArrowDown / ArrowUp / Home / End move between triggers).Accordion.Contentis the<section role="region">labelled by the trigger. The default styles animategrid-template-rowsfrom0frto1fr, giving you a smooth height transition without measuring anything.Accordion.Indicatoris an optional decorative<span>that mirrors the item’sdata-state. It applies no transform by default — drive transitions yourself viadata-[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".
- 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.
- 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.
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.
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.
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).
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.
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.
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
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";