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.
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.
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.
Without the plate
plate={false} drops the two bezels and leaves the bare list of rows — for a section that is already inside something.
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']
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.
| Layer | What it is |
|---|---|
| shell | The outer glass, seen only ever as a ring around the core |
| core | A top-lit gradient. What the rows are read against |
| bezel | 6px between the two, and the reason the radii are concentric |
| rule | One hairline between rows, never against the core’s edge |
| title | Brightens 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
| Prop | Type | |
|---|---|---|
| className | string | Added to the component’s own classes, so yours wins |
| style | CSSProperties | Merged 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.
| Prop | Type | Default | |
|---|---|---|---|
| items | AccordionItem[] | [] | |
| value | string[] | — | Controlled. Always an array |
| defaultValue | string[] | [] | |
| onValueChange | (value: string[]) => void | — | |
| multiple | boolean | false | Whether more than one may be open |
| collapsible | boolean | true | Whether the open one may be closed, on multiple={false} |
| padding | number | 14 | Vertical padding on a header |
| rules | boolean | true | Hairlines between the rows |
| plate | boolean | true | The toast’s two bezels around the whole thing |
| radius | number | 20 | The outer corner; the inner is this minus the bezel |
| theme | 'dark' | 'light' | 'dark' | |
| disabled | boolean | false |
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 | |
|---|---|
| Tab | Header to header, and into an open panel |
| ↓ ↑ | Walks the headers, wrapping, skipping disabled ones |
| Home / End | First and last |
| Enter / Space | Opens 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.