flyingsalmon

Accordion

Progressive disclosure in a vertical stack. Use it when extra information helps some readers but not all, and the page should stay clean without it. It peers to tabs and never substitutes for it: tabs switch between co-equal views, accordion reveals secondary content that stays in document flow.

There is no standalone collapsible. A one-item accordion with type="single" covers that need.

Several open at once

The default. type is multiple and collapsible is true, so the common case needs no props at all: every item opens and closes on its own. defaultValue takes an array of item values to open on first render, so two of the three below start open.

Light

Orders leave the warehouse within two working days and arrive inside a week, tracked from the moment they ship.

Two years against manufacturing defects, parts and labour included.
Dark

Orders leave the warehouse within two working days and arrive inside a week, tracked from the moment they ship.

Two years against manufacturing defects, parts and labour included.

One at a time, and collapsible

type="single" closes the open panel when another opens. collapsible stays true by default, so pressing the open item closes it and the stack can rest with nothing open. Pass collapsible={false} when one panel must always be visible.

Light

Four days in Kyoto, then two in Osaka, with the train booked between them.

Dark

Four days in Kyoto, then two in Osaka, with the train booked between them.

A disabled item

disabled on an item dims its trigger to half opacity, takes its pointer events away, and drops it from the arrow path. An item disabled while open keeps its panel readable — disabling never hides content. disabled on the root cascades to every trigger.

Light

Issued on 3 May and already sent to your email; it cannot be edited.

Dark

Issued on 3 May and already sent to your email; it cannot be edited.

Triggers that carry more than a label

A trigger takes any phrasing content, so a row can hold a status badge, a stacked summary line, or a title long enough to wrap. The chevron sits on the first line of the label, not in the middle of the block — a wrapped question and a two-line label both keep the affordance where the eye starts reading. Everything you pass lands inside the heading, so keep it to text and spans; a nested button is not allowed inside the trigger.

Light

Seats 14A and 14B, checked bags included. The airline releases boarding passes a day before departure.

Dark

Seats 14A and 14B, checked bags included. The airline releases boarding passes a day before departure.

Panels that do work

A panel is a plain container: fields, choices, and actions all belong in one. The panel measures itself when it opens and settles at height: auto, so content that appears afterwards — a validation message, an inline result — grows the panel instead of being clipped.

A result stays inside the panel that produced it. The acting surface owns its own outcome (ADR 0008), so the alert below sits under the actions row, in view, and stays until the reader has seen it. Controls inside a panel keep their own hover and focus states and leave the item’s divider alone — only the trigger steps it.

Light

Dark

Driven from outside

Pair value with onValueChange and the open set becomes the app’s state: expand all, collapse all, or open the one item a search matched. Under multiple both speak an array, so “expand all” is the list of every item value and “collapse all” is the empty array. Keep the array identity stable across renders — pass state, not a literal built in render.

Light
1 of 3 open

Fushimi Inari at dawn, then Nishiki Market for lunch.

Dark
1 of 3 open

Fushimi Inari at dawn, then Nishiki Market for lunch.

Props

Four parts: Accordion, AccordionItem, AccordionTrigger, and AccordionContent. The root takes type (multiple by default), collapsible (true by default), value, defaultValue, onValueChange, and disabled, all forwarded to Radix. orientation and dir are not exposed: the accordion is vertical only.

Controlled usage pairs value with onValueChange. Under multiple both speak an array of item values; under single both speak one value string, and an empty string means nothing is open. AccordionItem requires value and takes disabled. AccordionTrigger renders its label inside an h3; pass asChild with your own heading element to place the row at a different level in the document outline.

Surface

A divider list. Each item draws a 1px --border bottom edge and nothing else — no outer border, no surface step, no horizontal padding — so the stack sits flush with its container. A boxed accordion is the consumer’s call: wrap it in card content. Hover and keyboard focus paint the same pair at --motion-fast: the item’s divider steps to --indicator, and so does the chevron. There is no background step, no underline, and no ring in either state. The row draws no press ring either — it toggles on click and has nothing to hold, the same ground as a menu item.

The divider answers to the trigger, not the panel. The item watches its own [data-slot="accordion-trigger"] for hover and for :focus-visible, so a hovered or focused control inside an open panel leaves the divider at rest — checkboxes, fields, and buttons included. Only the row that opens the panel claims the divider under it.

Motion

Height only, from 0 to the Radix content height, with the panel clipped while it travels. Both directions run 150ms on spring-settle, transcribed to the linear() string the floating layer exits on. A panel displaces everything below it, and ADR 0001 keeps bounce off any dimension that does that: an overshooting height would push the panels under it past their place and drag them back, which reads as a glitch rather than play. There is no opacity fade either — a panel that fades while it grows reads as two effects fighting.

The height runs on CSS keyframes and Radix owns mount and unmount — no forceMount, no AnimatePresence, no layout prop, no open state of the component’s own. The chevron rotates 180 degrees on --motion-base, select’s rotation rule, with a color step to --indicator riding beside it at --motion-fast; rotation and height share the 150ms, so the row and its panel come to rest together.

Accessibility

Every trigger sits inside a heading, so the document outline survives. Tab moves between triggers, Up and Down walk the stack, Home and End jump to the first and last, Enter and Space toggle, and a disabled trigger is skipped. Focus never enters a closed panel. Each trigger carries aria-expanded, and its panel is a region labelled by the trigger.

A panel reserves room for the rings inside it. The panel clips itself so the height animation has an edge, which would cut the ring off a full-width field or a right-aligned button sitting flush against it. So the panel carries 6px of horizontal clearance — padding inside the clip, a matching negative margin outside it — the same trade the dialog body makes on its vertical axis. Content still lines up flush with the dividers.

The trigger draws no focus ring. A borderless row has no border to replace and no room for an offset ring without covering its neighbours, and an inset ring reads as a box around a row that has no box. So keyboard focus paints what hover paints: the item’s divider and the row’s chevron both step to --indicator (ADR 0003). The chevron is the half that stays inside the row when the panel is open and the divider sits below it. WCAG 2.4.7 asks for a visible focus indicator, not a ring — the same ground tabs stands on.

Measured against this theme’s own palette steps with an OKLCH-to-sRGB contrast check: the stepped divider and the stepped chevron are 3.17:1 on the light background and 5.75:1 on the dark one, the trigger label 18.25:1 and 17.48:1, the chevron at rest 5.17:1 and 7.04:1 — every pair clears WCAG AA in both modes, with the light step passing the 3:1 non-text bar it was flagged as tight against. Resting dividers are decorative and exempt.