Accordion
Accordion is a stack of collapsible sections where, by default, opening one closes the rest — the canonical shape for a settings sidebar, an inspector, or a grouped preferences panel where you want a lot on screen without it all being open at once. It's a thin coordinator on top of Collapsible: each AccordionItem publishes the very same disclosure context, so AccordionTrigger, AccordionPanel, and AccordionIndicator are literally the Collapsible parts wearing accordion names. The kit brings the open-state logic, the height animation, and the aria wiring; you bring the trigger markup.
<Surface outline className="w-96 overflow-hidden rounded-2xl">
<AccordionRoot defaultValue="appearance">
<AccordionItem value="appearance">
<AccordionTrigger>
<AccordionRow label="Appearance" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Theme, accent color, and interface density. Changes apply to the
whole workspace.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="notifications">
<AccordionTrigger>
<AccordionRow label="Notifications" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Choose what reaches your inbox: mentions, weekly digests, and
release notes.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="advanced">
<AccordionTrigger>
<AccordionRow label="Advanced" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Experimental flags, developer tooling, and the data export
endpoint.
</div>
</AccordionPanel>
</AccordionItem>
</AccordionRoot>
</Surface>Usage
import {
AccordionIndicator,
AccordionItem,
AccordionPanel,
AccordionRoot,
AccordionTrigger,
} from '@cladd-ui/react';
<AccordionRoot defaultValue="appearance">
<AccordionItem value="appearance">
<AccordionTrigger>
<button type="button" className="flex w-full justify-between px-4 py-2">
Appearance
<AccordionIndicator className="transition-transform data-[open]:rotate-90">
<ChevronIcon />
</AccordionIndicator>
</button>
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4">Theme, accent color, and density.</div>
</AccordionPanel>
</AccordionItem>
</AccordionRoot>;AccordionRoot owns the open value(s) — uncontrolled via defaultValue, controlled via value / onValueChange — and the single-vs-multiple mode. It renders no DOM of its own. Each AccordionItem binds a value and derives its own open state from the root. AccordionTrigger clones its single child to attach the toggle onClick plus the aria wiring (aria-expanded, aria-controls, id) and a data-open attribute — so a plain <button>, a Button, or a custom row all work. AccordionPanel animates its height between 0 and its measured natural height; put block padding on a nested element so the panel can collapse all the way to zero.
The chevron
cladd ships no icons — AccordionIndicator is an empty, aria-hidden slot you drop your own glyph into. Because it carries data-open, rotating a static chevron is pure CSS (data-[open]:rotate-90). For a full glyph swap (plus ↔ minus, say), pass a render function instead: {({ open }) => (open ? <MinusIcon /> : <PlusIcon />)}. You can also skip the component entirely and rotate an icon off the trigger's own data-open — the cloned child carries it too.
Examples
Each example below shares one small local helper, AccordionRow, so every AccordionItem stays a single line instead of repeating the full trigger markup. It's built on cladd's own Button — a transparent, full-width ghost — so the row picks up hover, active, and focus-ring states for free rather than hand-rolling them. Because AccordionTrigger clones its child to inject the toggle onClick, the aria wiring, and data-open, the row only has to spread those props (...props) straight onto the Button. That's the whole helper:
// `ChevronIcon` is your own glyph — cladd ships none.
function AccordionRow({
label,
...props
}: { label: string } & ComponentPropsWithoutRef<'button'>) {
return (
<Button
variant="transparent"
outline={false}
{...props}
className="w-full rounded-none aria-disabled:pointer-events-none aria-disabled:opacity-50"
contentClassName="justify-between px-4"
>
<span>{label}</span>
<AccordionIndicator className="text-cladd-fg-soft transition-transform data-[open]:rotate-90">
<ChevronIcon />
</AccordionIndicator>
</Button>
);
}Forgetting that {...props} spread is the one easy mistake here — without it the cloned onClick never reaches the Button and nothing toggles.
Single (default)
By default the accordion is single-open: opening one section collapses whatever was open, and re-toggling the open section closes it down to nothing. defaultValue sets which item starts expanded. This is the right mode for a settings panel where one focused section at a time keeps the screen legible.
<Surface outline className="w-96 overflow-hidden rounded-2xl">
<AccordionRoot defaultValue="appearance">
<AccordionItem value="appearance">
<AccordionTrigger>
<AccordionRow label="Appearance" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Theme, accent color, and interface density. Changes apply to the
whole workspace.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="notifications">
<AccordionTrigger>
<AccordionRow label="Notifications" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Choose what reaches your inbox: mentions, weekly digests, and
release notes.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="advanced">
<AccordionTrigger>
<AccordionRow label="Advanced" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Experimental flags, developer tooling, and the data export
endpoint.
</div>
</AccordionPanel>
</AccordionItem>
</AccordionRoot>
</Surface>Multiple
Pass multiple to let items open and close independently — nothing auto-collapses. The selection becomes a string[], so a controlled value is an array and onValueChange receives the full array. This is the FAQ / reference shape, where a reader may want several sections expanded side by side.
<Surface outline className="w-96 overflow-hidden rounded-2xl">
<AccordionRoot
multiple
value={open}
onValueChange={(v) => setOpen(v as string[])}
>
<AccordionItem value="shipping">
<AccordionTrigger>
<AccordionRow label="Shipping" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Ships in 2–3 business days. Free over $50.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="returns">
<AccordionTrigger>
<AccordionRow label="Returns" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
30-day window, no questions asked. We cover return postage.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="warranty">
<AccordionTrigger>
<AccordionRow label="Warranty" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Two years against manufacturing defects, extendable to five.
</div>
</AccordionPanel>
</AccordionItem>
</AccordionRoot>
</Surface>Controlled
Drive the open value yourself with value / onValueChange when the open state is owned by your app — synced to a route, persisted, or read elsewhere. In single-open mode onValueChange hands you a single string (or undefined when everything is closed); in multiple mode it hands you the array.
<div className="flex w-96 flex-col gap-2">
<span className="text-cladd-fg-soft">
Open section: <span className="text-cladd-fg">{open ?? 'none'}</span>
</span>
<Surface outline className="overflow-hidden rounded-2xl">
<AccordionRoot
value={open}
onValueChange={(v) => setOpen(v as string | undefined)}
>
<AccordionItem value="account">
<AccordionTrigger>
<AccordionRow label="Account" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Profile, email address, and connected sign-in providers.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="billing">
<AccordionTrigger>
<AccordionRow label="Billing" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Plan, payment method, and invoice history.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="team">
<AccordionTrigger>
<AccordionRow label="Team" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Invite members, manage roles, and revoke access.
</div>
</AccordionPanel>
</AccordionItem>
</AccordionRoot>
</Surface>
</div>Disabled
disabled on a single AccordionItem freezes just that item — its trigger stops toggling and reads as unavailable (a section gated behind a higher plan, say). disabled on AccordionRoot freezes every item at once. The disabled state flows through to the trigger as aria-disabled and data-disabled, so you can dim it in CSS.
<Surface outline className="w-96 overflow-hidden rounded-2xl">
<AccordionRoot defaultValue="general">
<AccordionItem value="general">
<AccordionTrigger>
<AccordionRow label="General" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Workspace name, default language, and time zone.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="integrations">
<AccordionTrigger>
<AccordionRow label="Integrations" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Slack, GitHub, and webhook connections.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="enterprise" disabled>
<AccordionTrigger>
<AccordionRow label="Enterprise (Pro plan)" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
SSO, audit logs, and custom retention policies.
</div>
</AccordionPanel>
</AccordionItem>
</AccordionRoot>
</Surface>Settings panel
The layout cladd was built for: a stack of sections whose panels hold real controls — an Input, a List of Switch rows — framed by a single Surface. Single-open keeps exactly one group expanded so a dense panel stays readable. Dense, but not crowded.
<Surface outline className="w-96 overflow-hidden rounded-2xl">
<AccordionRoot defaultValue="workspace">
<AccordionItem value="workspace">
<AccordionTrigger>
<AccordionRow label="Workspace" />
</AccordionTrigger>
<AccordionPanel>
<div className="flex flex-col gap-2 px-4 pb-4">
<SectionTitle>Display name</SectionTitle>
<Input value={name} onChange={setName} />
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="editor">
<AccordionTrigger>
<AccordionRow label="Editor" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-2 pb-2">
<List>
<ListButton
as="label"
after={
<Switch
as="span"
checked={toggles.autosave}
onChange={() => set('autosave')}
/>
}
>
Autosave drafts
</ListButton>
<ListButton
as="label"
after={
<Switch
as="span"
checked={toggles.cursors}
onChange={() => set('cursors')}
/>
}
>
Show collaborator cursors
</ListButton>
<ListButton
as="label"
after={
<Switch
as="span"
checked={toggles.compact}
onChange={() => set('compact')}
/>
}
>
Compact density
</ListButton>
</List>
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="notifications">
<AccordionTrigger>
<AccordionRow label="Notifications" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-2 pb-2">
<List>
<ListButton
as="label"
after={
<Switch
as="span"
checked={toggles.mentions}
onChange={() => set('mentions')}
/>
}
>
Mentions
</ListButton>
<ListButton
as="label"
after={
<Switch
as="span"
checked={toggles.digest}
onChange={() => set('digest')}
/>
}
>
Weekly digest
</ListButton>
<ListButton
as="label"
after={
<Switch
as="span"
checked={toggles.releases}
onChange={() => set('releases')}
/>
}
>
Release notes
</ListButton>
</List>
</div>
</AccordionPanel>
</AccordionItem>
</AccordionRoot>
</Surface>Playground
multiple and the accent color compose freely. Toggle multiple to switch between one-at-a-time and independent sections; the color token flows down to anything inside the panels that reads the surface accent.
<Surface
outline
className={`cladd-color-$brand w-96 overflow-hidden rounded-2xl`}
>
<AccordionRoot multiple={false} defaultValue="overview">
<AccordionItem value="overview">
<AccordionTrigger>
<AccordionRow label="Overview" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-primary">
The accent token flows down to anything that reads the surface
color.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="details">
<AccordionTrigger>
<AccordionRow label="Details" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
Toggle <span className="text-cladd-primary">multiple</span> to
let sections stay open independently.
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="more">
<AccordionTrigger>
<AccordionRow label="More" />
</AccordionTrigger>
<AccordionPanel>
<div className="px-4 pb-4 text-cladd-fg-soft">
In single mode, opening this collapses the others.
</div>
</AccordionPanel>
</AccordionItem>
</AccordionRoot>
</Surface>API Reference
AccordionRoot
Stateful, non-visual root for the set of sections. Owns the open value(s) and the single-vs-multiple mode, publishing them to its AccordionItems through context. Renders no DOM of its own.
| Name: Type | Default | Description |
|---|---|---|
| children: ReactNode | — | AccordionItems. |
| defaultValue: string | string[] | — | Initial open item(s) (uncontrolled). Ignored when value is provided. |
| disabled: boolean | — | Disable the whole accordion - every item's trigger stops toggling. |
| multiple: boolean | false | Allow more than one item open at once. Selection becomes an array. Default false. |
| onValueChange: (value: string | string[] | undefined) => void | — | Fires whenever the open item(s) change. Receives a single value (or undefined when all closed) in single-open mode, or the full array in multiple mode. |
| value: string | string[] | — | Controlled open item(s). A single value in single-open mode, an array when multiple.When provided, internal state is bypassed. |
AccordionItem
One section within an AccordionRoot. Binds a value, derives its open state from the surrounding accordion, and republishes it as a Collapsible context — so the disclosure parts below work unchanged inside it. Renders a single <div> wrapper carrying data-open / data-disabled.
| Name: Type | Default | Description |
|---|---|---|
| children: ReactNode | — | The item's trigger, panel and (optional) indicator. |
| className: string | — | Extra classes for the item wrapper. |
| disabled: boolean | — | Disable just this item. Combined with the accordion's own disabled. |
| value*: string | — | Identifies this item - matched against the accordion's open value(s). |
AccordionTrigger
An alias of CollapsibleTrigger. Clones its single child to attach the toggle onClick, the aria wiring (aria-expanded, aria-controls, id), and a data-open attribute. The child must accept onClick — a <button> is ideal. Composes with any existing onClick on the child, and no-ops while the item is disabled.
| Name: Type | Default | Description |
|---|---|---|
| children*: ReactNode | — | Single React element to use as the trigger. Must accept onClick (a <button> is ideal). |
AccordionPanel
An alias of CollapsiblePanel. The content region — animates its height between 0 and its measured natural height, carries the region role and aria wiring back to its trigger, and honors prefers-reduced-motion. Put block padding on a nested element so it collapses fully.
| Name: Type | Default | Description |
|---|---|---|
| as: ElementType | 'div' | Polymorphic root element. Defaults to 'div'. |
| children: ReactNode | — | Panel content. Put block padding on a nested element - the panel itself animates its height to zero, so vertical padding on it would keep it from fully collapsing. |
| className: string | — | Extra classes for the panel root. |
| keepMounted: boolean | false | Keep the panel mounted (at height: 0) while collapsed instead of unmounting it - preserves internal state and scroll position.Default false: the panel still animates closed, then unmounts once the animation finishes. |
AccordionIndicator
An alias of CollapsibleIndicator. An optional, aria-hidden slot for the open/closed affordance. Pass static markup and rotate it off data-open in CSS, or pass a render function receiving { open, disabled } to swap glyphs entirely. Brings no glyph of its own.
| Name: Type | Default | Description |
|---|---|---|
| as: ElementType | 'span' | Polymorphic root element. Defaults to 'span'. |
| children: ReactNode | ((state: CollapsibleIndicatorState) => ReactNode) | — | Indicator content. Either static markup (rotate or restyle it off the data-open attribute in CSS), or a render function receiving { open, disabled } so you can swap glyphs entirely (chevron, plus↔minus, a spinning ×, whatever). |
| className: string | — | Extra classes for the indicator root. |