Collapsible
A single disclosure that animates open and closed — built on Base UI's Collapsible primitive with four variants, five sizes, rich trigger slots, tone-aware theming and full RTL support.
pnpm add @tessinaui/uiWhen to use
- Secondary content hidden by default: advanced form options, an FAQ row, an order-summary breakdown, an expandable list row.
- A single disclosure on its own. One block that opens in place, with no other disclosure it has to coordinate with.
- A summary that stays visible.
trailingTextanddescriptionkeep the total or the hint on the closed trigger, so people can decide whether to expand. - A short, descriptive trigger label. People decide whether to expand from the label alone, so name what is inside.
- Independent rows in a list. Several stay open at once and long questions wrap rather than truncate (FAQ list).
When not to use
- Content most people need. Never hide it behind a click; show it on the page.
- A set where only one should stay open. Use Accordion, which adds arrow-key movement and the single-or-multiple-open semantic.
- A row that navigates. Its chevron points along the reading direction and belongs to a list or menu; a disclosure's chevron points down and reveals in place.
- Floating revealed content. Use Popover or HoverCard.
- Views people switch between. Peer sections belong in Tabs, not in stacked disclosures.
Installation
pnpm add @tessinaui/uiUsage
import {
CollapsibleRoot,
CollapsibleHeader,
CollapsibleTrigger,
CollapsiblePanel,
} from "@tessinaui/ui";<CollapsibleRoot variant="outline">
<CollapsibleHeader>
<CollapsibleTrigger>Show advanced options</CollapsibleTrigger>
</CollapsibleHeader>
<CollapsiblePanel>
Tweak request timeouts, retry policy, and SDK telemetry — most teams leave
these on the defaults.
</CollapsiblePanel>
</CollapsibleRoot>Examples
Default
A basic disclosure with the outline variant, closed by default.
Variants
Four visual treatments — ghost, outline, filled, and elevated. filled is a card for a grouped page, the same card Card, Fieldset and Form draw, so the example sits it on one.
Sizes
Five sizes from xs to xl scale the trigger and panel together.
Indicators
Built-in indicator sets — chevron, plus, caret, none — plus the
contained style and the start side. All three knobs live on the root.
Rich trigger
leadingIcon, description and trailingText slots, with the header
rendered as a real heading via headingLevel. The slots reach the button
through aria-describedby — the accessible name stays the title alone.
Order summary
The checkout idiom: trailingText keeps the total visible while collapsed,
the breakdown reveals on demand.
Header actions
Actions are siblings of the trigger inside CollapsibleHeader — never
children of the button. Set fullWidth={false} so the trigger shares the row,
and pass the root's rounded to the actions so their corners (and hover fill)
follow the shell.
FAQ list
Independent disclosures in a divider list — several stay open at once, and long questions wrap rather than truncate. For a single-open group with arrow keys, use Accordion.
Nested
caret carries hierarchy semantics (leading, points along the reading
direction closed, down open — the disclosure triangle). Rotation state is
scoped per disclosure, so levels never leak into each other.
Form panel
A controlled disclosure hosting real form controls. onOpenChangeComplete
fires when the height animation settles — the moment to focus a revealed
control or lazy-load panel data.
Open emphasis
Style the open state through Base UI's data attributes — data-open on the
root, data-panel-open on the trigger — no extra prop needed.
On color
Inside a Surface, Collapsible inherits tone="on-color": ink, borders, washes
and the indicator derive from the surface ink published by the plate
underneath. Pass tone="on-color" yourself only on a plate something else
paints; an explicit tone always wins.
Intents
A subtle border accent for status context on non-ghost variants.
Loading
Skeleton placeholders mirror the collapsed and open layouts. label takes
the real title's character count so the bar measures what the title measures.
RTL
Set dir="rtl" and the indicator moves to the left edge. The default chevron
points down and up, so it does not flip. The caret points along the reading
direction while closed, so under RTL it points left. Leave dir unset and the
disclosure follows the page.
API Reference
CollapsibleRoot props
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Scales trigger padding, text, indicator, and panel padding |
variant | "ghost" | "outline" | "filled" | "elevated" | "ghost" | Visual treatment — ghost is just trigger + panel, the others wrap everything in a shell |
rounded | "none" | "sm" | "md" | "lg" | "full" | "lg" | §2 panel map (full caps at rounded-3xl). Rounds the shell — or the ghost trigger itself |
intent | "none" | "primary" | "error" | "warning" | "success" | "info" | "none" | Border accent on non-ghost shells. No-op on ghost |
indicator | "chevron" | "plus" | "caret" | "none" | "chevron" | Trigger indicator set. caret = leading hierarchy semantics |
indicatorStyle | "contained" | "plain" | "plain" | Bare glyph, or a tinted circle behind it |
iconSide | "start" | "end" | "end" | Which edge the indicator sits on (logical — mirrors in RTL) |
tone | "default" | "on-color" | inherited | on-color derives chrome from the surface ink. Inherited from a wrapping <Surface>, else "default"; an explicit prop wins |
dir | "ltr" | "rtl" | — | No default: direction inherits from <html dir>; set only to override |
open / defaultOpen / onOpenChange | — | — | Controlled / uncontrolled state. onOpenChange(open, eventDetails) — eventDetails carries the reason and a cancel() |
onOpenChangeComplete | (open: boolean) => void | — | Fires when the height animation settles (immediately under reduced motion) |
disabled | boolean | false | Disables the trigger and prevents toggling |
render | ReactElement | (props, state) => ReactElement | — | Base UI polymorphic prop (the asChild equivalent) |
CollapsibleHeader props
A flex row for the trigger plus any sibling actions.
| Prop | Type | Default | Description |
|---|---|---|---|
headingLevel | 2 | 3 | 4 | 5 | 6 | — | Renders the header as a real <h2>–<h6> so the disclosure joins the page outline. Keep actions outside a heading header |
CollapsibleTrigger props
| Prop | Type | Default | Description |
|---|---|---|---|
leadingIcon | ReactNode | — | Decorative media before the title — kept out of the accessible name |
description | ReactNode | — | Supporting line under the title, associated via aria-describedby |
trailingText | ReactNode | — | Meta before the indicator (value, count, price) — also aria-describedby |
customIndicator | ReactNode | — | Replaces the built-in indicator glyph |
fullWidth | boolean | true | Stretch the trigger to fill its container |
render / nativeButton | — | — | Base UI polymorphism — render as a different element while preserving trigger behaviour |
Renders a <button> with aria-expanded, plus aria-controls while the
panel is open — Base UI omits it while closed, when the panel is not in the
DOM (APG disclosure). The title wraps rather than truncating. Keyboard: Enter and
Space toggle; a lone disclosure has no arrow-key behaviour by design.
Row layout. trailingText sits at the trailing edge, vertically centred
on the whole text block — the same axis as leadingIcon and the indicator —
while it fits beside the title and description. When it does not (typically on
a phone, or at 200% text zoom), it wraps under the description, at the
leading edge, and the description keeps the full width instead of being
squeezed. The wrap is content-driven, never a breakpoint, and the value is never
hidden. The indicator is chrome outside that block, pinned to the trigger's
trailing edge. Keep trailingText short — a value, a count, a price — and put
anything longer in description.
CollapsiblePanel props
| Prop | Type | Default | Description |
|---|---|---|---|
keepMounted | boolean | false | Keep the panel in the DOM while closed. Ignored when hiddenUntilFound is set |
hiddenUntilFound | boolean | false | hidden="until-found" — closed content stays reachable by find-in-page; a browser match reveals the panel one-way and state syncs through onOpenChange |
withPadding | boolean | true | Size-scaled panel padding (set false when wrapping a Card). The panel clips for its height animation, so flush content keeps focus rings inside itself — a nested CollapsibleRoot does this for you |
render | ReactElement | (props, state) => ReactElement | — | Base UI polymorphic prop |
CollapsibleSkeleton props
| Prop | Type | Default | Description |
|---|---|---|---|
variant / size / rounded / iconSide / indicatorStyle | — | component defaults | Mirrors the real component's frame — same class maps, touch floor included |
open | boolean | false | Render the open-panel placeholder |
lines | number | 3 | Body text lines in the open panel |
label | number | 20 | Character count of the real title — the bar measures what the title measures |
showIndicator | boolean | true | Render the indicator placeholder |
Accessibility
Pattern: WAI-ARIA Disclosure.
- A real button with its state.
CollapsibleTriggerrenders a<button>witharia-expanded(andaria-controlswhile open) from@base-ui/react/collapsible, so collapsed and expanded are announced without any work on your side. - The name is the title alone.
descriptionandtrailingTextreach the trigger througharia-describedby;leadingIconand the indicator glyph arearia-hidden, since the state already rides onaria-expanded. Your ownaria-labelledby/aria-describedbymerge with these rather than replacing them. - Headings are opt-in.
headingLevelonCollapsibleHeaderwraps the trigger in a heading, so the disclosure joins the page outline where it should. - Header actions are siblings, never children, of the trigger — interactive
content inside a
<button>is unreachable (see the header-actions example). - Closed content can stay findable.
hiddenUntilFoundlets find-in-page reach a closed panel and reveal it; state syncs throughonOpenChange. - Moving focus into revealed content belongs in
onOpenChangeComplete, which fires once the height animation settles (immediately under reduced motion). - Contrast and touch — the focus ring is the house 2px ring on
:focus-visible. Onghost(the default) it sits outside the trigger over a 2px gap; the shelled variants clip, so theirs is inset — and so is a ghost trigger nested in another collapsible's panel, which clips for its height animation. Supporting ink (description, meta) is stepped up on the hover wash, where the muted tier measured 4.34:1 in light; axecolor-contrastis clean on the demo and playground in both themes. Triggers clear 44px on phones. - Motion — the height transition is
motion-safe, so reduced motion gets an instant toggle.
Keyboard
| Key | Behaviour |
|---|---|
Tab / Shift+Tab | Moves to the trigger, then into the panel's content when open |
Enter / Space | Toggles the panel |
| Arrow keys | Nothing — a lone disclosure has no arrow-key behaviour by design. For a set, use Accordion |
AT matrix
| AT | Status |
|---|---|
| VoiceOver + Chrome (macOS) | Passed 2026-09-09 (headed Chromium) — the name stays the title alone, the aria-describedby slots are read, and collapsed → expanded is announced on VO+Space |
| VoiceOver + Safari (macOS) | UNTESTED — Safari blocked the scripted route used for this smoke |
| NVDA + Chrome (Windows) | UNTESTED — no Windows host available |
| JAWS | UNTESTED |
One NVDA pass is still owed under QA flow §5.6.
Notes
- Built on
@base-ui/react/collapsible. State attributes for styling and testing:data-open/data-closed/data-starting-style/data-ending-styleon root and panel,data-panel-openon the trigger,data-disabledeverywhere while disabled. Every part also carries a stabledata-slot(collapsible-root,-header,-trigger,-trigger-title,-trigger-description,-trigger-meta,-trigger-icon,-indicator,-panel,-panel-content,-skeleton) for agents and tests. - The animation transitions
heightbetween0andvar(--collapsible-panel-height)— any content height, no measurement, and it ismotion-safe-gated so reduced motion gets an instant toggle. hiddenUntilFoundneeds the panel to participate in layout — never style the paneldisplay: none/contentsyourself. Safari support forhidden="until-found"is recent (late 2025); older Safari degrades to hidden-until-opened with no find-in-page reveal.- For multi-item disclosures, prefer Accordion. For floating revealed content, prefer Popover or HoverCard. For clamped "show more" text, a dedicated spoiler component is tracked in TES-187.