# Accordion (/docs/accordion)





`Accordion` is a stack of expandable sections: each item is a heading wrapping a trigger button, and clicking it reveals that item's panel. Content never animates via Ark's own JS-measured height — the same category of runtime-measurement quirk this kit already worked around for [`Slider`](/docs/slider)'s `thumbSize` — instead every panel renders through [`Collapse`](/docs/collapse), this kit's own zero-JS, CSS-grid-rows animation primitive.

```tsx
import { Accordion } from '@42-central/ui-react/accordion';

<Accordion
  data={[
    { value: 'shipping', label: 'Shipping', content: 'Orders ship within 2 business days.' },
    { value: 'returns', label: 'Returns & refunds', content: 'Returns accepted within 30 days.' },
  ]}
/>
```

## Playground [#playground]

<story.Playground />

## Items [#items]

`data` is a flat array of `{ value, label, content, disabled?, icon? }` — each section needs its own `content`, so (unlike [`SegmentGroup`](/docs/segment-group)) there's no bare-string shorthand.

```tsx
<Accordion
  data={[
    { value: 'shipping', label: 'Shipping', content: <Shipping /> },
    { value: 'returns', label: 'Returns & refunds', content: <Returns /> },
    { value: 'payment', label: 'Payment methods', content: <Payment />, disabled: true },
  ]}
/>
```

Each item also accepts an optional `testId`, defaulting to `` `accordion-${value}` `` — the matching content panel gets `` `${testId}-content` ``. See the [Testing](/docs/testing) guide. The compound `Accordion.Item` / `Accordion.ItemTrigger` / `Accordion.ItemContent` / `Accordion.Root` parts each take their own `testId` prop directly, for the same reason.

## Variants [#variants]

Two treatments — a different vocabulary than [`Button`](/docs/button)'s own `filled`/`light`/`outline`/`subtle`. `default` is a single bordered list with dividers between items; `separated` lays each item out as its own bordered card with a gap between them, its border deepening to a solid neutral ink while open. `radius` (ignored by `default`) rounds the `separated` cards.

<Preview align="center" code="&#x22;<Accordion variant=\&#x22;default\&#x22; data={[{ value: 'a', label: 'Shipping', content: 'Ships in 2 days.' }, { value: 'b', label: 'Returns', content: 'Accepted within 30 days.' }]} />\n<Accordion variant=\&#x22;separated\&#x22; data={[{ value: 'a', label: 'Shipping', content: 'Ships in 2 days.' }, { value: 'b', label: 'Returns', content: 'Accepted within 30 days.' }]} />&#x22;">
  <AccordionVariantsDemo />
</Preview>

## Sizes [#sizes]

Five sizes — `xs` through `xl` — matching the rest of the family. Sizing lives on the triggers and panels.

<Preview align="center" code="&#x22;<Accordion size=\&#x22;xs\&#x22; data={[{ value: 'a', label: 'Shipping', content: 'Ships in 2 days.' }]} defaultValue={['a']} />\n<Accordion size=\&#x22;md\&#x22; data={[{ value: 'a', label: 'Shipping', content: 'Ships in 2 days.' }]} defaultValue={['a']} />\n<Accordion size=\&#x22;xl\&#x22; data={[{ value: 'a', label: 'Shipping', content: 'Ships in 2 days.' }]} defaultValue={['a']} />&#x22;">
  <div className="flex w-full flex-col items-stretch gap-3">
    <Accordion size="xs" data="[{ value: 'a', label: 'Shipping', content: 'Ships in 2 days.' }]" defaultValue="['a']" />

    <Accordion size="md" data="[{ value: 'a', label: 'Shipping', content: 'Ships in 2 days.' }]" defaultValue="['a']" />

    <Accordion size="xl" data="[{ value: 'a', label: 'Shipping', content: 'Ships in 2 days.' }]" defaultValue="['a']" />
  </div>
</Preview>

## Multiple [#multiple]

By default only one section can be open at a time. Pass `multiple` to let any number stay expanded together.

<story.Preview variant="Multiple" />

## Collapsible [#collapsible]

In single mode (the default), the one open section can't be closed by re-clicking its own trigger — some other section must be opened instead. Pass `collapsible` to allow closing it directly, leaving every section collapsed.

<story.Preview variant="Collapsible" />

## Chevron [#chevron]

`chevronPosition` (`"start" | "end"`, default `"end"`) is a logical position, not a physical left/right — it flips correctly under `dir="rtl"` with no extra CSS (the same convention [`Divider`](/docs/divider)'s `labelPosition` uses).

<story.Preview variant="Chevron start" />

Pass `chevron` to swap the icon entirely, or `chevron={null}` to hide it — there's no separate flag to keep the default icon but disable its open/close rotation.

```tsx
import { ChevronDown } from 'lucide-react';

<Accordion chevron={<ChevronDown />} data={SECTIONS} />
<Accordion chevron={null} data={SECTIONS} />
```

## Disabled [#disabled]

A single item can be disabled via `{ value, label, content, disabled: true }` — it's skipped by keyboard navigation and can't be opened. Pass `disabled` on `Accordion` itself to disable every item at once.

<story.Preview variant="Disabled item" />

## Compound API [#compound-api]

Drop to the composable parts for anything the flat `data` array can't express — custom trigger content, interleaved non-item elements, or driving the value from your own state. `variant` / `size` / `radius` / `chevronPosition` / `chevron` / `order` all live on `Accordion.Root` — there's no separate `List` part the way [`Tabs`](/docs/tabs) has one, since the root itself *is* the list — and cascade to every item through context.

```tsx
'use client';
import { Accordion } from '@42-central/ui-react/accordion';

<Accordion.Root variant="separated" defaultValue={['shipping']}>
  <Accordion.Item value="shipping">
    <Accordion.ItemTrigger>Shipping</Accordion.ItemTrigger>
    <Accordion.ItemContent>Orders ship within 2 business days.</Accordion.ItemContent>
  </Accordion.Item>
  <Accordion.Item value="returns">
    <Accordion.ItemTrigger>Returns & refunds</Accordion.ItemTrigger>
    <Accordion.ItemContent>Returns accepted within 30 days.</Accordion.ItemContent>
  </Accordion.Item>
</Accordion.Root>
```

## Controlled [#controlled]

Drive the value from state with the same `value` / `onChange` pair either way: the data-driven form takes them as top-level props, and the compound `Accordion.Root` accepts them too — it flattens Ark's own `onValueChange` into `onChange` for you, so `onValueChange` isn't a prop on either form.

```tsx
'use client';
import { useState } from 'react';
import { Accordion } from '@42-central/ui-react/accordion';

export function FaqAccordion() {
  const [open, setOpen] = useState<string[]>(['shipping']);
  return (
    <Accordion
      value={open}
      onChange={setOpen}
      data={[
        { value: 'shipping', label: 'Shipping', content: <Shipping /> },
        { value: 'returns', label: 'Returns & refunds', content: <Returns /> },
      ]}
    />
  );
}
```

## API [#api]

<TypeTable
  type="{
  data: {
    type: '{ value, label, content, disabled?, icon? }[]',
    description: 'Sections, in order. Each needs its own content — there is no bare-string shorthand.',
  },
  value: {
    type: 'string[]',
    description: 'Controlled expanded values.',
  },
  defaultValue: {
    type: 'string[]',
    default: '[]',
    description: 'Uncontrolled initial expanded values.',
  },
  onChange: {
    type: '(value: string[]) => void',
    description: 'Fires with the next expanded values (flattened from Ark\'s onValueChange).',
  },
  multiple: {
    type: 'boolean',
    default: 'false',
    description: 'Allow more than one section to stay expanded at once.',
  },
  collapsible: {
    type: 'boolean',
    default: 'false',
    description: &#x22;In single mode, allow closing the one open section by re-clicking its own trigger.&#x22;,
  },
  variant: {
    type: '&#x22;default&#x22; | &#x22;separated&#x22;',
    default: '&#x22;default&#x22;',
    description: 'Container + item treatment — a different vocabulary than Button\'s filled/light/outline/subtle.',
  },
  size: {
    type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;',
    default: '&#x22;md&#x22;',
    description: 'Trigger + content sizing.',
  },
  radius: {
    type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;',
    default: '&#x22;sm&#x22;',
    description: 'Corner rounding for separated item cards. Ignored by default.',
  },
  chevronPosition: {
    type: '&#x22;start&#x22; | &#x22;end&#x22;',
    default: '&#x22;end&#x22;',
    description: 'Logical chevron position on each trigger — flips under dir=&#x22;rtl&#x22;.',
  },
  chevron: {
    type: 'ReactNode | null',
    description: 'Custom chevron icon. null hides it entirely.',
  },
  order: {
    type: '2 | 3 | 4 | 5 | 6',
    default: '3',
    description: 'Semantic heading level wrapping each trigger.',
  },
  disabled: {
    type: 'boolean',
    description: 'Disables every item at once.',
  },
  orientation: {
    type: '&#x22;horizontal&#x22; | &#x22;vertical&#x22;',
    default: '&#x22;vertical&#x22;',
    description: 'Lay the items in a column or a row.',
  },
  testId: {
    type: 'string',
    description: 'data-testid on the root. See the per-item testId field above for the data-driven items themselves.',
  },
}"
/>

## Accessibility [#accessibility]

* Built on Ark UI's Accordion machine and the [WAI-ARIA Accordion pattern](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/): each trigger is a real `<button>` wrapped in a heading (`<h2>`..`<h6>` via `order`, default `<h3>`), with `aria-expanded` and `aria-controls` linking it to its `role="region"` panel.
* `↓` / `↑` (or `→` / `←` when `orientation="horizontal"`) move focus between triggers and wrap; `Home` / `End` jump to the first/last trigger. `Enter` / `Space` toggles the focused trigger, same as any native button.
* A `disabled` item's trigger is a real disabled `<button>` — skipped by arrow-key navigation and unclickable.
* RTL is honored via the document `dir`; `chevronPosition` is logical (`start`/`end`), not physical.
