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.

Theme, accent color, and interface density. Changes apply to the whole workspace.
<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.

Theme, accent color, and interface density. Changes apply to the whole workspace.
<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.

Ships in 2–3 business days. Free over $50.
<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.

Open section: account
Profile, email address, and connected sign-in providers.
<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.

Workspace name, default language, and time zone.
<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.

Display name
<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.

The accent token flows down to anything that reads the surface color.
<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.

children: ReactNodeAccordionItems.
defaultValue: string | string[]Initial open item(s) (uncontrolled). Ignored when value is provided.
disabled: booleanDisable the whole accordion - every item's trigger stops toggling.
multiple: booleanfalseAllow more than one item open at once. Selection becomes an array. Default false.
onValueChange: (value: string | string[] | undefined) => voidFires 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.

children: ReactNodeThe item's trigger, panel and (optional) indicator.
className: stringExtra classes for the item wrapper.
disabled: booleanDisable just this item. Combined with the accordion's own disabled.
value*: stringIdentifies 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.

children*: ReactNodeSingle 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.

C extends ElementType = 'div'
as: ElementType'div'Polymorphic root element. Defaults to 'div'.
children: ReactNodePanel 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: stringExtra classes for the panel root.
keepMounted: booleanfalseKeep 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.

C extends ElementType = 'span'
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: stringExtra classes for the indicator root.