Navigation

Accordion

Sections that open, at a height that is measured rather than guessed at. Every header is its own tab stop, and a closed section is not hidden — it is gone.

Usage

import { Accordion } from '@carabine/ui/accordion';

Add what you want to the basket and check out. Nothing is charged until the order is confirmed, and the confirmation arrives by email within a minute.

<Accordion
  items={[
    { id: 'orders', title: 'How do I place an order?', content: <p>…</p> },
    { id: 'shipping', title: 'When will it ship?', content: <p>…</p> },
    { id: 'refunds', title: 'How do I request a refund?', content: <p>…</p> },
  ]}
  defaultValue={['orders']}
/>

value is always an array, even when only one section may be open. A component whose value changes shape with a boolean prop is one you have to read twice.

Anatomy

There are no sub-components to compose. The sections are data, because a list of sections in a real application is something you build, filter and reorder — so what you pass is the list.

<Accordion>              // the plate: two bezels, glass outside, a lit core inside
  <h3>                   //   the heading — how a screen reader's outline finds it
    <button>             //   the header — aria-expanded, aria-controls, its own tab stop
      {icon}
      {title}
      {description}      //   an optional second line
      <Chevron />        //   turns 180° on open
    </button>
  </h3>
  <section>              //   the panel — role="region", aria-labelledby, height: auto
    {content}            //   padding lives here, on the inner box, not on the animating one
  </section>
</Accordion>

The radii are concentric — the inner corner is the outer minus the bezel — because two rounded things nested need that relationship or the corners run at each other. Inside the card the rules only ever go between rows: the core's own edge is already the boundary, and a rule against it is two lines saying the same thing.

Examples

Several at once

multiple lets any number be open. On multiple={false}, collapsible={false} is what stops the last one closing — one at a time and always one are different promises, and conflating them is how an accordion ends up with nothing open and no way to say that was not allowed.

Add what you want to the basket and check out. Nothing is charged until the order is confirmed, and the confirmation arrives by email within a minute.

Same day for anything ordered before 2pm, next working day after that. Tracking is sent the moment the parcel leaves.

<Accordion items={faq} multiple defaultValue={['orders', 'shipping']} />

Icons and descriptions

A header takes an icon before the title and a description under it. A disabled row is stepped over by the arrow keys rather than merely dimmed.

<Accordion
  items={[
    {
      id: 'delivery',
      icon: <Package className="size-4" />,
      title: 'Delivery',
      description: 'Where it is, and when it lands',
      content: <p>…</p>,
    },
    {
      id: 'returns',
      icon: <RotateCcw className="size-4" />,
      title: 'Returns',
      description: 'Not available on this plan',
      disabled: true,
      content: <p>…</p>,
    },
  ]}
/>

Without the plate

plate={false} drops the two bezels and leaves the bare list of rows — for a section that is already inside something.

<Accordion items={faq} plate={false} />

Controlled

Pass value and onValueChange together. Leave value off and the component keeps its own, seeded by defaultValue.

Same day for anything ordered before 2pm, next working day after that. Tracking is sent the moment the parcel leaves.

value = ['shipping']

const [value, setValue] = useState<string[]>(['shipping']);

<Accordion items={faq} value={value} onValueChange={setValue} multiple />

Customization

Four numbers and two booleans, and they are props rather than literals because a consumer might reasonably want another value for any of them.

<Accordion
  items={faq}
  padding={18}      // vertical padding on a header; the type follows it
  radius={24}       // the outer corner — the inner one is this minus the bezel
  rules={false}     // no hairlines between rows
  plate={false}     // no card around the whole thing
  theme="light"     // both palettes are written out; there is no dark: variant
  disabled          // the whole thing, at half opacity, answering nothing
/>

Styling reference

Tailwind utilities; there is no stylesheet for this component. The card is the toast's material — glass outside, a top-lit core inside, the rows on the core. No surface appears anywhere on hover, because a row is not a thing you rest on, it is a thing you open.

LayerWhat it is
shellThe outer glass, seen only ever as a ring around the core
coreA top-lit gradient. What the rows are read against
bezel6px between the two, and the reason the radii are concentric
ruleOne hairline between rows, never against the core’s edge
titleBrightens on group-hover, in CSS. The affordance is the cursor and the label

Three timings, and none of them match: the height takes 300ms, the opacity 180ms so the text is gone before the box has finished closing on it, and the content lifts six pixels over the full 300ms. Matching the fade to the height makes the last few frames a line of text being guillotined.

API reference

PropType
classNamestringAdded to the component’s own classes, so yours wins
styleCSSPropertiesMerged after the component’s own inline styles, so yours wins

Colours are data and dimensions are props, and neither covers a margin, a font, or a class from your own system. That is what these are for. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.

PropTypeDefault
itemsAccordionItem[][]
valuestring[]Controlled. Always an array
defaultValuestring[][]
onValueChange(value: string[]) => void
multiplebooleanfalseWhether more than one may be open
collapsiblebooleantrueWhether the open one may be closed, on multiple={false}
paddingnumber14Vertical padding on a header
rulesbooleantrueHairlines between the rows
platebooleantrueThe toast’s two bezels around the whole thing
radiusnumber20The outer corner; the inner is this minus the bezel
theme'dark' | 'light''dark'
disabledbooleanfalse

Keyboard

Every header is its own tab stop — not a roving tabindex. Tabs and segmented controls use one because they are a single control with several settings; an accordion is several controls with several panels, and Tab is how you get from one to the next. The arrows walk the headers on top of that.

Key
TabHeader to header, and into an open panel
↓ ↑Walks the headers, wrapping, skipping disabled ones
Home / EndFirst and last
Enter / SpaceOpens and closes

A closed section is unmounted, not held at zero height — anything focusable inside it would otherwise still be in the tab order. For the 260ms of the collapse the panel is still in the document, so it takes inert and aria-hidden the instant it starts leaving: only its pixels take the time.