Accordion
Stacked sections that expand one or several at a time.
- Status
- Experimental
- Level
- Molecule
- Category
- Layout
- Adoption
- Not used yet
import { Accordion } from "@oration/canon/components/accordion";packages/canon/src/components/accordion.tsxACH payments land one to two business days after the run. Wires and same-day ACH land the day of the run if it sends before 2:00 PM CT.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@oration/canon/components/accordion";export function Hero() { return ( <div className="w-full max-w-lg rounded-xl bg-card px-4 py-1 shadow-border"> <Accordion defaultValue={["timing"]}> <AccordionItem value="timing"> <AccordionTrigger> When does a payment reach my bank? </AccordionTrigger> <AccordionContent className="text-13 text-muted-foreground"> <p> ACH payments land one to two business days after the run. Wires and same-day ACH land the day of the run if it sends before 2:00 PM CT. </p> </AccordionContent> </AccordionItem> <AccordionItem value="bank"> <AccordionTrigger> How do I change my bank details? </AccordionTrigger> <AccordionContent className="text-13 text-muted-foreground"> <p> Upload a signed bank letter in the supplier portal. Cedarline verifies it with a call to the number on file before the next run. </p> </AccordionContent> </AccordionItem> <AccordionItem value="short"> <AccordionTrigger> Why was my invoice short-paid? </AccordionTrigger> <AccordionContent className="text-13 text-muted-foreground"> <p> Usually a credit memo or a price mismatch against the purchase order. The remittance lists every deduction with its reason. </p> </AccordionContent> </AccordionItem> </Accordion> </div> );}Usage#
Accordion stacks titled sections, split by hairlines, that open one at a time or several at once. It suits reference content people scan by heading, such as the supplier FAQ an agent draws on or the checks behind a payment run. It is experimental and not used in the product yet. The common mistake is hiding short facts behind triggers: if the answer is one line, show it as a row instead.
When to use
- For question-and-answer content people scan by heading: the supplier FAQ, knowledge snippets, help articles.
- For a short list of checks or sections where each has a paragraph of detail, such as the checks before a payment run.
- When only one section needs to be open at a time and the rest can fold away.
- For long configuration notes inside a sheet, where the headings alone give the overview.
When not to use
- For one region that shows and hides behind its own trigger. Use Collapsible
- For switching between peer views of the same object. Use Tabs
- For one-line facts about a record. Show them as rows. Use Item
- For a record's attribute groups in the side rail, which use collapsible sections. Use Record page
- For steps someone completes in order. Use Stepper
Disclosure motion
Hairlines are structure
border-b hairlines, a structural divider. An accordion inside a card stays flat; its items never become cards.Anatomy#
ACH lands in one to two business days.
- Item.
AccordionItem: one section. Every item but the last draws a hairline bottom rule. - Trigger.
AccordionTrigger: a full-width button inside an<h3>, 14px medium, 10px vertical padding. - Chevron. A 16px Slate Meta chevron at the right. It points down when closed and up when open.
- Panel.
AccordionContent: the section body. Height animates open and closed; 10px of bottom padding.
Examples#
One at a time
The default. Opening a section closes the one that was open. The value is still an array: defaultValue={["w9"]}.
US suppliers need a current W-9 on file before Cedarline can pay them. Payments hold until it arrives.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@oration/canon/components/accordion";export function Single() { return ( <Accordion defaultValue={["w9"]} className="max-w-md"> <AccordionItem value="w9"> <AccordionTrigger>Why do you need a W-9?</AccordionTrigger> <AccordionContent className="text-13 text-muted-foreground"> <p> US suppliers need a current W-9 on file before Cedarline can pay them. Payments hold until it arrives. </p> </AccordionContent> </AccordionItem> <AccordionItem value="upload"> <AccordionTrigger>Where do I upload it?</AccordionTrigger> <AccordionContent className="text-13 text-muted-foreground"> <p> Use the secure link in the request email. It expires after 14 days; ask the agent for a new one any time. </p> </AccordionContent> </AccordionItem> <AccordionItem value="release"> <AccordionTrigger> When are held payments released? </AccordionTrigger> <AccordionContent className="text-13 text-muted-foreground"> <p>On the next payment run after the W-9 is verified.</p> </AccordionContent> </AccordionItem> </Accordion> );}Several open
multiple lets sections stay open together, here the checks behind the Friday wire run with a status label beside each title.
Halcyon Supply, Orchard Street Bakery and Cobalt Office Supply have no current W-9. Their invoices are held from this run.
All 48 suppliers have verified bank details. Northwind Freight changed theirs on Sep 12 and was verified by call.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@oration/canon/components/accordion";import { StatusLabel } from "@oration/canon/components/status-dot";export function Multiple() { const checks = [ { value: "w9", title: "Tax forms", status: { tone: "warning" as const, label: "3 held" }, body: "Halcyon Supply, Orchard Street Bakery and Cobalt Office Supply have no current W-9. Their invoices are held from this run.", }, { value: "bank", title: "Bank details", status: { tone: "success" as const, label: "Verified" }, body: "All 48 suppliers have verified bank details. Northwind Freight changed theirs on Sep 12 and was verified by call.", }, { value: "dupes", title: "Duplicate invoices", status: { tone: "success" as const, label: "None found" }, body: "No invoice number repeats for the same supplier in the last 180 days.", }, ]; return ( <Accordion multiple defaultValue={["w9", "bank"]} className="max-w-md"> {checks.map((check) => ( <AccordionItem key={check.value} value={check.value}> <AccordionTrigger className="items-center gap-3"> <span className="flex-1">{check.title}</span> <StatusLabel tone={check.status.tone} className="text-xs font-normal" > {check.status.label} </StatusLabel> </AccordionTrigger> <AccordionContent className="text-13 text-muted-foreground"> <p>{check.body}</p> </AccordionContent> </AccordionItem> ))} </Accordion> );}Controlled, with expand all
value and onValueChange hold the open items in state, so one button can open or close every section of the agent's call script.
Thanks for calling Cedarline supplier support. Can I have your supplier ID or the invoice number?
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@oration/canon/components/accordion";import { Button } from "@oration/canon/components/button";import * as React from "react";export function Controlled() { const sections = [ { value: "greeting", title: "Greeting", body: "Thanks for calling Cedarline supplier support. Can I have your supplier ID or the invoice number?", }, { value: "lookup", title: "Invoice lookup", body: "Read the status, the payment run and the expected arrival date. Never read full bank account numbers aloud.", }, { value: "handoff", title: "Handoff", body: "Transfer to Contact Center when the caller disputes an amount or asks for a manager.", }, ]; const [open, setOpen] = React.useState<string[]>(["greeting"]); const allOpen = open.length === sections.length; return ( <div className="flex w-full max-w-md flex-col gap-3"> <div className="flex items-center justify-between"> <span className="text-xs text-muted-foreground tabular-nums"> {open.length} of {sections.length} open </span> <Button type="button" variant="ghost" size="sm" onClick={() => setOpen( allOpen ? [] : sections.map((section) => section.value), ) } > {allOpen ? "Collapse all" : "Expand all"} </Button> </div> <Accordion multiple value={open} onValueChange={setOpen}> {sections.map((section) => ( <AccordionItem key={section.value} value={section.value}> <AccordionTrigger>{section.title}</AccordionTrigger> <AccordionContent className="text-13 text-muted-foreground"> <p>{section.body}</p> </AccordionContent> </AccordionItem> ))} </Accordion> </div> );}Disabled item
disabled on an item dims its trigger and blocks it. Say why beside the label.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@oration/canon/components/accordion";export function Disabled() { return ( <Accordion className="max-w-md"> <AccordionItem value="ach"> <AccordionTrigger>ACH</AccordionTrigger> <AccordionContent className="text-13 text-muted-foreground"> <p> Next-day ACH through JPMorgan Chase, cut-off 5:00 PM CT. </p> </AccordionContent> </AccordionItem> <AccordionItem value="rtp" disabled> <AccordionTrigger> Real-time payments <span className="ml-2 font-normal text-muted-foreground"> Available after bank approval </span> </AccordionTrigger> <AccordionContent className="text-13 text-muted-foreground"> <p>Instant payments through the RTP network.</p> </AccordionContent> </AccordionItem> </Accordion> );}States#
import { cn } from "@oration/canon/lib/utils";import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";export function States() { const states = [ { label: "Closed", className: "", open: false, disabled: false }, { label: "Hover", className: "underline", open: false, disabled: false, }, { label: "Focus visible", className: "border-ring ring-3 ring-ring/50", open: false, disabled: false, }, { label: "Open", className: "", open: true, disabled: false }, { label: "Disabled", className: "opacity-50", open: false, disabled: true, }, ]; return ( <div className="grid w-full gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-5"> {states.map((state) => ( <div key={state.label} className="flex min-w-0 flex-col gap-1"> <span className="text-xs text-muted-foreground"> {state.label} </span> <div className={cn( "flex items-start justify-between rounded-lg border border-transparent py-2.5 text-sm font-medium", state.className, )} > Bank details {state.open ? ( <ChevronUpIcon aria-hidden="true" className="size-4 text-muted-foreground" /> ) : ( <ChevronDownIcon aria-hidden="true" className="size-4 text-muted-foreground" /> )} </div> </div> ))} </div> );}| State | Treatment |
|---|---|
| Closed | The trigger alone, with a down chevron. |
| Hover | The trigger's label underlines. There is no background change. |
| Focus visible | An indigo border and a 3px Focus Indigo ring at 50% around the trigger. |
| Open | aria-expanded is true, the chevron points up and the panel grows to its content height over 200ms. |
| Disabled | Set disabled on the item or the root. The trigger dims to 50% and ignores pointer and keyboard. |
Behavior#
- Single by default: opening one item closes the other. Pass
multipleto let several stay open. - The value is always an array of item values, even in single mode:
defaultValue={["timing"]}. Give every item avalue; without one Base UI generates an ID you can't control. - Uncontrolled with
defaultValue, or controlled withvalueandonValueChange(value, details), which is how Expand all and Collapse all work. - Closed panels unmount by default. Pass
keepMountedto keep them in the DOM, orhiddenUntilFoundso the browser's find-in-page can search and open them. - The panel's height animates with the
accordion-downandaccordion-upkeyframes against--accordion-panel-height, over 200ms ease-out. classNameonAccordionContentgoes on the inner wrapper, not the panel, so padding and text styles apply inside the animated region.
Do and don't#
2:00 PM CT on business days.
Content#
- Write triggers as the question or topic people look for: How do I change my bank details?, Tax forms.
- Keep triggers to one line. Put status beside the label as a status label, not in the heading text.
- Open the most likely answer by default when one stands out, and leave the rest closed.
- Panel copy is reading text: full sentences in 13 or 14px, links underlined.
Accessibility#
- Each trigger is a
<button>inside an<h3>, witharia-expandedandaria-controlspointing at its panel. The panel is aregionlabelled by its trigger. - The heading level is fixed at
h3. Place accordions where anh3fits the page outline, under anh2section. - Base UI 1.8 follows the updated APG pattern: triggers are in the normal tab order and arrow keys don't move between them.
- The chevrons are decorative; state comes from
aria-expanded. - The height animation isn't removed under reduced motion; the global reduced-motion rules only cover Collapsible.
| Keys | Action |
|---|---|
| Tab | Moves to the next trigger or into an open panel. |
| Enter | Opens or closes the focused section. |
| Space | Opens or closes the focused section. |
Design tokens#
| Token | Used for |
|---|---|
--border | Hairline between items |
--muted-foreground | Chevrons |
--ring | Focus border and 3px ring at 50% |
--accordion-panel-height | Set by Base UI; drives the open and close animation |
animate-accordion-down | Open: height from 0, 200ms ease-out |
animate-accordion-up | Close: height to 0, 200ms ease-out |
API reference#
Accordion
The root. Holds which items are open.
Other props spread onto Base UI Accordion.Root (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | any[] | No default | Values of the items open on first render. Uncontrolled. |
value | any[] | No default | Values of the open items. Controlled. |
onValueChange | (value: any[], eventDetails) => void | No default | Called when an item opens or closes. |
multiple | boolean | false | Lets several items stay open at once. |
disabled | boolean | false | Disables every item. |
keepMounted | boolean | false | Keeps closed panels in the DOM. |
hiddenUntilFound | boolean | false | Hides closed panels with hidden="until-found" so find-in-page can open them. Overrides keepMounted. |
orientation | "vertical" | "horizontal" | "vertical" | Sets data-orientation. The styles assume vertical. |
AccordionItem
One section, ruled off from the next by a hairline.
Other props spread onto Base UI Accordion.Item (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
value | any | No default | Identifies the item in value and defaultValue. |
disabled | boolean | false | Disables this item. |
onOpenChange | (open: boolean, eventDetails) => void | No default | Called when this item opens or closes. |
AccordionTrigger
The button, wrapped in an h3, with the chevron appended after children.
Other props spread onto Base UI Accordion.Trigger (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged onto the button. The h3 can't be styled. |
AccordionContent
The animated panel.
Other props spread onto Base UI Accordion.Panel (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Applied to the inner wrapper, which carries the padding and text styles. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Accordion isn't imported anywhere in apps/web. Grouped disclosure in the product (record attribute sections, task sections, the API key scopes) uses Collapsible.
The trigger underlines on hover, a link affordance. Other disclosure triggers in the suite fill Well Gray on hover instead.
The chevron swaps between two icons (ChevronDownIcon and ChevronUpIcon) instead of rotating, so the change doesn't animate while Collapsible triggers rotate theirs over 200ms.
The height animation has no reduced-motion override. Collapsible snaps under reduced motion; Accordion keeps animating.
The heading is always an h3 and can't take a class or a different level.
The inner wrapper carries data-starting-style:h-0 and data-ending-style:h-0, but those attributes are set on the panel, not the wrapper, so the classes never match.