Skip to content

Accordion

Stacked sections that expand one or several at a time.

Category
Layout
Adoption
Not used yet
import { Accordion } from "@oration/canon/components/accordion";
packages/canon/src/components/accordion.tsx

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.

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

Opening and closing animate height only, over 200ms on ease-out, within the 200 to 250ms disclosure range. Nothing else in the accordion moves.

Hairlines are structure

Items are split by border-b hairlines, a structural divider. An accordion inside a card stays flat; its items never become cards.

Anatomy#

Payment timing

ACH lands in one to two business days.

Bank details
  1. Item. AccordionItem: one section. Every item but the last draws a hairline bottom rule.
  2. Trigger. AccordionTrigger: a full-width button inside an <h3>, 14px medium, 10px vertical padding.
  3. Chevron. A 16px Slate Meta chevron at the right. It points down when closed and up when open.
  4. 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.

1 of 3 open

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#

Closed
Bank details
Hover
Bank details
Focus visible
Bank details
Open
Bank details
Disabled
Bank details
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>    );}
States
StateTreatment
ClosedThe trigger alone, with a down chevron.
HoverThe trigger's label underlines. There is no background change.
Focus visibleAn indigo border and a 3px Focus Indigo ring at 50% around the trigger.
Openaria-expanded is true, the chevron points up and the panel grows to its content height over 200ms.
DisabledSet 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 multiple to let several stay open.
  • The value is always an array of item values, even in single mode: defaultValue={["timing"]}. Give every item a value; without one Base UI generates an ID you can't control.
  • Uncontrolled with defaultValue, or controlled with value and onValueChange(value, details), which is how Expand all and Collapse all work.
  • Closed panels unmount by default. Pass keepMounted to keep them in the DOM, or hiddenUntilFound so the browser's find-in-page can search and open them.
  • The panel's height animates with the accordion-down and accordion-up keyframes against --accordion-panel-height, over 200ms ease-out.
  • className on AccordionContent goes 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.

Do. Use an accordion for answers of a sentence or more that people find by their question.

Don't. Fold one-line facts such as a bank account or payment terms behind triggers. Show them as rows people can read at a glance.

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>, with aria-expanded and aria-controls pointing at its panel. The panel is a region labelled by its trigger.
  • The heading level is fixed at h3. Place accordions where an h3 fits the page outline, under an h2 section.
  • 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.
Keyboard interactions
KeysAction
TabMoves to the next trigger or into an open panel.
EnterOpens or closes the focused section.
SpaceOpens or closes the focused section.

Design tokens#

Design tokens
TokenUsed for
--borderHairline between items
--muted-foregroundChevrons
--ringFocus border and 3px ring at 50%
--accordion-panel-heightSet by Base UI; drives the open and close animation
animate-accordion-downOpen: height from 0, 200ms ease-out
animate-accordion-upClose: 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>).

Props of Accordion
PropTypeDefaultDescription
defaultValueany[]No defaultValues of the items open on first render. Uncontrolled.
valueany[]No defaultValues of the open items. Controlled.
onValueChange(value: any[], eventDetails) => voidNo defaultCalled when an item opens or closes.
multiplebooleanfalseLets several items stay open at once.
disabledbooleanfalseDisables every item.
keepMountedbooleanfalseKeeps closed panels in the DOM.
hiddenUntilFoundbooleanfalseHides 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>).

Props of AccordionItem
PropTypeDefaultDescription
valueanyNo defaultIdentifies the item in value and defaultValue.
disabledbooleanfalseDisables this item.
onOpenChange(open: boolean, eventDetails) => voidNo defaultCalled 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>).

Props of AccordionTrigger
PropTypeDefaultDescription
classNamestringNo defaultMerged onto the button. The h3 can't be styled.

AccordionContent

The animated panel.

Other props spread onto Base UI Accordion.Panel (<div>).

Props of AccordionContent
PropTypeDefaultDescription
classNamestringNo defaultApplied 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.