Skip to content

Collapsible

One region that shows and hides behind a trigger, animating its height.

Status
Stable
Category
Layout
Adoption
Not used yet
import { Collapsible } from "@oration/canon/components/collapsible";
packages/canon/src/components/collapsible.tsx

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

The panel's height animates over 200ms on the house ease-out (--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

A right chevron turns 90° to point down (or a down chevron turns 180°) over the same 200ms, keyed off data-panel-open on the trigger.

Anatomy#

Payment details
Terms
Net 30
Method
ACH
  1. Root. Collapsible: holds the open state. Renders a <div>, or any element through render, such as a <section>.
  2. Trigger. CollapsibleTrigger: a <button> with aria-expanded. Put it inside a heading when it names a section, and give it a chevron.
  3. Panel. CollapsibleContent: the region that animates its height between 0 and --collapsible-panel-height, with overflow: 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#

Closed
Tax
Hover
Tax
Focus visible
Tax
Open
Tax
Disabled
Tax
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>    );}
States
StateTreatment
ClosedThe panel is unmounted (or hidden with keepMounted) and the chevron points right.
HoverUp to the trigger's styling. Section triggers fill Well Gray; icon buttons use the ghost hover.
Focus visibleUp to the trigger. Section triggers draw a 3px Focus Indigo ring at 40%; buttons bring their own.
OpenThe trigger gets data-panel-open and aria-expanded="true"; the panel gets data-open and grows to its content height over 200ms.
Opening and closingdata-starting-style and data-ending-style pin the height at 0 at either end of the transition.
Disableddisabled 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 with open and onOpenChange(open, eventDetails).
  • The height transition comes from globals.css: [data-slot="collapsible-content"] sets overflow: hidden, height: var(--collapsible-panel-height) and transition: height 200ms var(--ease-out). Under prefers-reduced-motion: reduce the transition is removed.
  • Closed panels unmount by default. keepMounted keeps them in the DOM; hiddenUntilFound hides them with hidden="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-controls automatically; you don't pass IDs.
  • render works on every part, so a trigger can be a Button and a root can be a <section>. The trigger can also sit inside a Tooltip trigger.

Do and don't#

Do. Name what opens: Retry settings, Payment details, with a chevron that turns.
Don't. Label the trigger More or leave a bare chevron. People can't tell what will open until they try it.

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> with aria-expanded and aria-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-label that 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 add motion-reduce:transition-none too.
  • Use hiddenUntilFound for long reference content, so find-in-page reaches text inside closed panels.
Keyboard interactions
KeysAction
TabMoves to the trigger, then into the open panel.
EnterOpens or closes the panel.
SpaceOpens or closes the panel.

Design tokens#

Design tokens
TokenUsed for
--collapsible-panel-heightSet by Base UI; the panel's animated height
--ease-outThe height transition curve
--mutedHover fill of section triggers
--ringFocus ring on section triggers, 3px at 40%
--muted-foregroundChevrons and counts

API reference#

Collapsible

The root. Sets data-slot="collapsible".

Other props spread onto Base UI Collapsible.Root (<div>).

Props of Collapsible
PropTypeDefaultDescription
defaultOpenbooleanfalseWhether the panel starts open. Uncontrolled.
openbooleanNo defaultWhether the panel is open. Controlled.
onOpenChange(open: boolean, eventDetails) => voidNo defaultCalled when the trigger opens or closes the panel.
disabledbooleanfalseIgnores interaction with the trigger.
renderReactElement | (props, state) => ReactElementNo defaultRender 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>).

Props of CollapsibleTrigger
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender 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>).

Props of CollapsibleContent
PropTypeDefaultDescription
keepMountedbooleanfalseKeeps the panel in the DOM while closed.
hiddenUntilFoundbooleanfalseHides 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.