Collapsible
One region that shows and hides behind a trigger, animating its height.
Remittance emails
Sent to suppliers after every payment run
import { Button } from "@oration/canon/components/button";import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@oration/canon/components/collapsible";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { cn } from "@oration/canon/lib/utils";import { ChevronDownIcon, MailIcon } from "lucide-react";import * as React from "react";export function Hero() { const baseId = React.useId(); const [open, setOpen] = React.useState(true); const [enabled, setEnabled] = React.useState(true); const [bcc, setBcc] = React.useState(false); return ( <Collapsible open={open} onOpenChange={setOpen} className="w-full max-w-lg rounded-xl bg-card shadow-border" > <div className="flex flex-wrap items-center gap-3 px-4 py-3"> <span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-muted/70 text-muted-foreground"> <MailIcon aria-hidden="true" className="size-4" /> </span> <div className="min-w-0 flex-1 basis-40"> <p className="text-sm font-medium text-foreground"> Remittance emails </p> <p id={`${baseId}-state`} className="text-13 text-muted-foreground" > {enabled ? "Sent to suppliers after every payment run" : "Suppliers get no remittance email"} </p> </div> <div className="ml-auto flex items-center gap-2"> <Switch checked={enabled} aria-label="Remittance emails" aria-describedby={`${baseId}-state`} onCheckedChange={(checked) => { setEnabled(checked); toast.add({ title: checked ? "Remittance emails on" : "Remittance emails off", }); }} /> <Tooltip> <TooltipTrigger render={ <CollapsibleTrigger render={ <Button type="button" variant="ghost" size="icon-sm" aria-label={ open ? "Hide email settings" : "Show email settings" } /> } /> } > <ChevronDownIcon aria-hidden="true" className={cn( "transition-transform duration-200 ease-out motion-reduce:transition-none", open && "rotate-180", )} /> </TooltipTrigger> <TooltipContent> {open ? "Hide email settings" : "Show email settings"} </TooltipContent> </Tooltip> </div> </div> <CollapsibleContent> <div className="flex flex-col gap-4 border-t border-border px-4 py-4"> <div className="flex flex-col gap-1.5"> <Label htmlFor={`${baseId}-from`}>Send from</Label> <Input id={`${baseId}-from`} defaultValue="payments@cedarline.io" className="font-mono text-xs" /> </div> <div className="flex items-center justify-between gap-4"> <label htmlFor={`${baseId}-bcc`} className="text-13"> Copy the AP team <span className="block text-xs text-muted-foreground"> Sends a blind copy to ap@cedarline.io. </span> </label> <Switch id={`${baseId}-bcc`} checked={bcc} onCheckedChange={setBcc} /> </div> </div> </CollapsibleContent> </Collapsible> );}Usage#
Collapsible is one region that shows and hides behind its trigger, with a 200ms height animation that snaps under reduced motion. It powers the record attribute sections, the task list groups, the invite link panel and the reasoning in Thinking and Tool chip. The component is unstyled Base UI: you bring the trigger, usually a chevron that rotates, and the panel. What people get wrong is the trigger's name: More or a bare chevron says nothing about what opens.
When to use
- For a section of a side rail or list that people fold away, like Payment details on a record or Completed in tasks.
- For advanced or rarely changed settings under a form or a settings row, such as Retry settings.
- For a card whose summary row stays visible and whose details open below it, like the invite link panel.
- For secondary detail inside a row, such as a tool call's input and output.
When not to use
- For a stack of sections where opening one should close the others. Use Accordion
- For switching between peer views. Use Tabs
- For content that floats over the page and dismisses on outside click. Use Popover
- For a short explanation of a term. Use Info tip
- For errors or warnings people must see. Don't fold them away. Use Alert
Disclosure motion
--ease-out), inside the 200 to 250ms disclosure range. Under reduced motion it snaps open and closed with no transition.Chevrons rotate, they don't swap
data-panel-open on the trigger.Anatomy#
- Terms
- Net 30
- Method
- ACH
- Root.
Collapsible: holds the open state. Renders a<div>, or any element throughrender, such as a<section>. - Trigger.
CollapsibleTrigger: a<button>witharia-expanded. Put it inside a heading when it names a section, and give it a chevron. - Panel.
CollapsibleContent: the region that animates its height between 0 and--collapsible-panel-height, withoverflow: hidden.
Examples#
Rail section
The record attribute pattern: a trigger inside an h3 fills Well Gray on hover, and its chevron turns 90° from group-data-panel-open.
- Terms
- Net 30
- Method
- ACH
- Bank
- Chase, ending 4417
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@oration/canon/components/collapsible";import { ChevronRightIcon } from "lucide-react";export function Section() { const sections = [ { title: "Payment details", open: true, rows: [ ["Terms", "Net 30"], ["Method", "ACH"], ["Bank", "Chase, ending 4417"], ], }, { title: "Tax", open: false, rows: [ ["W-9", "Received Aug 4"], ["TIN match", "Passed"], ], }, ]; return ( <div className="w-full max-w-xs rounded-xl bg-card shadow-border"> {sections.map((section) => ( <Collapsible key={section.title} defaultOpen={section.open} className="border-b border-border px-3 py-3 last:border-b-0" > <h3> <CollapsibleTrigger className="group/section flex h-7 w-full items-center gap-1 rounded-md px-1.5 text-left text-13 font-medium outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40"> <ChevronRightIcon aria-hidden="true" className="size-3.5 text-muted-foreground transition-transform duration-200 ease-out group-data-panel-open/section:rotate-90 motion-reduce:transition-none" /> {section.title} </CollapsibleTrigger> </h3> <CollapsibleContent> <dl className="flex flex-col gap-1.5 px-1.5 pt-1.5 text-13"> {section.rows.map(([term, value]) => ( <div key={term} className="flex justify-between gap-4" > <dt className="text-muted-foreground"> {term} </dt> <dd className="text-foreground">{value}</dd> </div> ))} </dl> </CollapsibleContent> </Collapsible> ))} </div> );}List group with a count
The tasks pattern: each group renders as a <section> through render, with a 36px header row and a tabular count. Completed starts closed.
- Call Halcyon about the missing W-9
- Approve the Sep 30 ACH run
- Confirm Northwind's new bank letter
- Reply to Orchard Street's dispute
- Review Friday wire run
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@oration/canon/components/collapsible";import { cn } from "@oration/canon/lib/utils";import { ChevronRightIcon } from "lucide-react";export function GroupWithCount() { const groups = [ { id: "overdue", label: "Overdue", open: true, tasks: [ "Call Halcyon about the missing W-9", "Approve the Sep 30 ACH run", ], }, { id: "week", label: "Due this week", open: true, tasks: [ "Confirm Northwind's new bank letter", "Reply to Orchard Street's dispute", "Review Friday wire run", ], }, { id: "done", label: "Completed", open: false, tasks: ["Send Q3 1099 reminders", "Close TCK-4102"], }, ]; return ( <div className="w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border"> {groups.map((group) => ( <Collapsible key={group.id} defaultOpen={group.open} render={<section aria-label={group.label} />} > <h3 className="border-b border-border bg-surface"> <CollapsibleTrigger className="group/section flex h-9 w-full items-center gap-1.5 px-4 text-left text-13 font-medium outline-none focus-visible:bg-muted"> <ChevronRightIcon aria-hidden="true" className="size-3.5 text-muted-foreground transition-transform duration-200 ease-out group-data-panel-open/section:rotate-90 motion-reduce:transition-none" /> <span className={cn( group.id === "overdue" && "text-destructive", )} > {group.label} </span> <span className="font-normal text-muted-foreground tabular-nums"> {group.tasks.length} </span> </CollapsibleTrigger> </h3> <CollapsibleContent> <ul> {group.tasks.map((task) => ( <li key={task} className="flex h-9 items-center border-b border-border px-4 pl-10 text-13 text-foreground" > {task} </li> ))} </ul> </CollapsibleContent> </Collapsible> ))} </div> );}Advanced settings in a form
A ghost sm trigger under the main field reveals rarely changed settings in a tint well. The form's one filled button stays outside the panel.
import { Button } from "@oration/canon/components/button";import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@oration/canon/components/collapsible";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import { ChevronRightIcon } from "lucide-react";import * as React from "react";export function Advanced() { const baseId = React.useId(); return ( <form className="flex w-full max-w-sm flex-col gap-4" onSubmit={(event) => { event.preventDefault(); toast.add({ type: "success", title: "Webhook saved" }); }} > <div className="flex flex-col gap-1.5"> <Label htmlFor={`${baseId}-url`}>Endpoint URL</Label> <Input id={`${baseId}-url`} defaultValue="https://erp.cedarline.io/hooks/payments" className="font-mono text-xs" /> </div> <Collapsible className="flex flex-col gap-3"> <CollapsibleTrigger render={ <Button type="button" variant="ghost" size="sm" className="group/advanced -ml-2 w-fit text-muted-foreground" /> } > <ChevronRightIcon data-icon="inline-start" aria-hidden="true" className="transition-transform duration-200 ease-out group-data-panel-open/advanced:rotate-90 motion-reduce:transition-none" /> Retry settings </CollapsibleTrigger> <CollapsibleContent> <div className="grid grid-cols-2 gap-3 rounded-[10px] bg-muted/70 p-3"> <div className="flex flex-col gap-1.5"> <Label htmlFor={`${baseId}-retries`}>Retries</Label> <Input id={`${baseId}-retries`} type="number" defaultValue={5} className="bg-background tabular-nums" /> </div> <div className="flex flex-col gap-1.5"> <Label htmlFor={`${baseId}-timeout`}> Timeout (s) </Label> <Input id={`${baseId}-timeout`} type="number" defaultValue={10} className="bg-background tabular-nums" /> </div> </div> </CollapsibleContent> </Collapsible> <div className="flex justify-end"> <Button type="submit">Save webhook</Button> </div> </form> );}States#
import { cn } from "@oration/canon/lib/utils";import { ChevronRightIcon } from "lucide-react";export function States() { const states = [ { label: "Closed", className: "", open: false }, { label: "Hover", className: "bg-muted", open: false }, { label: "Focus visible", className: "ring-3 ring-ring/40", open: false, }, { label: "Open", className: "", open: true }, { label: "Disabled", className: "opacity-50", open: false }, ]; return ( <div className="grid w-full gap-4 sm:grid-cols-3 lg:grid-cols-5"> {states.map((state) => ( <div key={state.label} className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> {state.label} </span> <div className={cn( "flex h-7 items-center gap-1 rounded-md px-1.5 text-13 font-medium", state.className, )} > <ChevronRightIcon aria-hidden="true" className={cn( "size-3.5 text-muted-foreground", state.open && "rotate-90", )} /> Tax </div> </div> ))} </div> );}| State | Treatment |
|---|---|
| Closed | The panel is unmounted (or hidden with keepMounted) and the chevron points right. |
| Hover | Up to the trigger's styling. Section triggers fill Well Gray; icon buttons use the ghost hover. |
| Focus visible | Up to the trigger. Section triggers draw a 3px Focus Indigo ring at 40%; buttons bring their own. |
| Open | The trigger gets data-panel-open and aria-expanded="true"; the panel gets data-open and grows to its content height over 200ms. |
| Opening and closing | data-starting-style and data-ending-style pin the height at 0 at either end of the transition. |
| Disabled | disabled on the root makes the trigger ignore interaction. Style it yourself; the wrapper adds no dimming. |
Behavior#
- Uncontrolled with
defaultOpen(closed by default), or controlled withopenandonOpenChange(open, eventDetails). - The height transition comes from
globals.css:[data-slot="collapsible-content"]setsoverflow: hidden,height: var(--collapsible-panel-height)andtransition: height 200ms var(--ease-out). Underprefers-reduced-motion: reducethe transition is removed. - Closed panels unmount by default.
keepMountedkeeps them in the DOM;hiddenUntilFoundhides them withhidden="until-found"so find-in-page can open them. - Style the trigger from its state with
group-data-panel-open/…:classes, as the record attribute sections do for their chevron. - Trigger and panel are linked with
aria-controlsautomatically; you don't pass IDs. renderworks on every part, so a trigger can be aButtonand a root can be a<section>. The trigger can also sit inside a Tooltip trigger.
Do and don't#
Content#
- Trigger labels name the hidden content: Retry settings, Tax, Show quoted text.
- When the label changes with the state, say both: Show email settings and Hide email settings.
- Section triggers can carry a count after the label in Slate Meta tabular figures: Due this week 3.
- Don't hide required fields or errors inside a closed panel.
Accessibility#
- The trigger is a real
<button>witharia-expandedandaria-controls. Base UI keeps them in sync. - When the trigger names a section, wrap it in a heading (
<h2>or<h3>) so the section appears in the page outline, as the record attribute sections do. - Icon-only triggers need an
aria-labelthat says what they show or hide, and a tooltip with the same words. - The height animation is removed under reduced motion by
globals.css; chevron rotations should addmotion-reduce:transition-nonetoo. - Use
hiddenUntilFoundfor long reference content, so find-in-page reaches text inside closed panels.
| Keys | Action |
|---|---|
| Tab | Moves to the trigger, then into the open panel. |
| Enter | Opens or closes the panel. |
| Space | Opens or closes the panel. |
Design tokens#
| Token | Used for |
|---|---|
--collapsible-panel-height | Set by Base UI; the panel's animated height |
--ease-out | The height transition curve |
--muted | Hover fill of section triggers |
--ring | Focus ring on section triggers, 3px at 40% |
--muted-foreground | Chevrons and counts |
API reference#
Collapsible
The root. Sets data-slot="collapsible".
Other props spread onto Base UI Collapsible.Root (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | false | Whether the panel starts open. Uncontrolled. |
open | boolean | No default | Whether the panel is open. Controlled. |
onOpenChange | (open: boolean, eventDetails) => void | No default | Called when the trigger opens or closes the panel. |
disabled | boolean | false | Ignores interaction with the trigger. |
render | ReactElement | (props, state) => ReactElement | No default | Render as another element, such as <section>. |
CollapsibleTrigger
The toggle button. Sets data-slot="collapsible-trigger" and data-panel-open while open.
Other props spread onto Base UI Collapsible.Trigger (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render as another element, usually a Button. |
CollapsibleContent
The panel. Sets data-slot="collapsible-content", which picks up the height transition from globals.css.
Other props spread onto Base UI Collapsible.Panel (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
keepMounted | boolean | false | Keeps the panel in the DOM while closed. |
hiddenUntilFound | boolean | false | Hides the closed panel with hidden="until-found" so find-in-page can open it. Overrides keepMounted. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The trigger ships unstyled, so each product call site writes its own. Record attribute sections, task sections and the invite link panel use three different hover, focus and chevron treatments.
Chevron rotations at the call sites don't add motion-reduce:transition-none, so the icon still turns under reduced motion while the panel snaps.
The invite link panel rotates its chevron with an inline style over 150ms while the panel opens over 200ms.