A single disclosure widget pairing a trigger with a panel that expands and collapses — the single-item analogue of Accordion.
Playground
Preview
import { Collapsible, CollapsibleTrigger, CollapsibleTriggerIcon, CollapsibleContent } from "@/components/ui/collapsible";import { ChevronDown } from "@primitiv-ui/icons";
<Collapsible variant="plain" size="md" defaultOpen> <CollapsibleTrigger> Section title <CollapsibleTriggerIcon> <ChevronDown aria-hidden="true" /> </CollapsibleTriggerIcon> </CollapsibleTrigger> <CollapsibleContent>{/* ... */}</CollapsibleContent></Collapsible>import { Collapsible } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";
<Collapsible.Root defaultOpen> <Collapsible.Trigger> Section title <Collapsible.TriggerIcon> <ChevronDown aria-hidden="true" /> </Collapsible.TriggerIcon> </Collapsible.Trigger> <Collapsible.Content>{/* ... */}</Collapsible.Content></Collapsible.Root>Density is set by a data-density ancestor — the Context system, not a Collapsible prop.
Installation
npx primitiv add collapsiblepnpm dlx primitiv add collapsibleyarn dlx primitiv add collapsiblebunx primitiv add collapsibleImport
import { Collapsible } from "@/components/ui/collapsible";Copied into your project as .primitiv-collapsible — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
<Collapsible> <CollapsibleTrigger> <CollapsibleTriggerIcon /> </CollapsibleTrigger> <CollapsibleContent /></Collapsible><Collapsible.Root> <Collapsible.Trigger> <Collapsible.TriggerIcon /> </Collapsible.Trigger> <Collapsible.Content /></Collapsible.Root>Props
Collapsible.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| defaultOpen | boolean | false | headless | Initial open state on first render. The component owns the value
thereafter.
Forbidden in controlled mode — use open instead. |
| disabled | boolean | false | headless | When true, disables the widget: the CollapsibleTriggerProps * Trigger} renders aria-disabled="true" (staying focusable) and both
click and keyboard activation are short-circuited. Mirrored as
data-disabled onto Root, Trigger, and Content. |
| onOpenChange | (open: boolean) => void | — | headless | Called with the new open state on every toggle. Optional in uncontrolled mode (Collapsible fires it in both modes). Called with the requested open state on every toggle. Required in controlled mode. |
| open | boolean | — | headless | Forbidden in uncontrolled mode — use defaultOpen instead.
The controlled open state. Must be kept in sync by the parent via
onOpenChange. |
| variant | "plain" | "card" | "inline" | plain | styled | Visual style of the widget. |
| size | "xs" | "sm" | "md" | "lg" | "xl" | md | styled | Control size for the whole widget; data-density scales each size further. |
Collapsible.Trigger
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | Trigger content (label, and optionally a
CollapsibleTriggerIconPropsTriggerIcon). |
| asChild | boolean | false | headless | Render a single consumer-supplied child element instead of the default
<button>, merging the Trigger's ARIA, handlers, and ref onto it via
the Slot pattern. When asChild and the Root is disabled,
role="button" is injected so aria-disabled is semantically valid. |
| ref | Ref<T> | — | headless | Forwarded to the rendered element. Defaults to HTMLButtonElement;
when using asChild, specify the child's element type (e.g.
HTMLAnchorElement). Composed with the library's internal ref. |
Collapsible.Content
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | Panel content. |
| collapsedHeight | string | number | — | headless | Show a clamped preview of the panel while closed instead of hiding it
entirely — the "collapsed height" / read-more pattern. A number is taken
as pixels; a string is used verbatim as a CSS length (e.g. "4rem",
"20cqi"). When set:
- the panel is always mounted and never gets the hidden attribute or
aria-hidden (the preview is real, readable content), so forceMount is
implied;
- the value is emitted as the --primitiv-collapsible-collapsed-height
custom property on the panel, which the styling layer clamps the closed
height to (e.g. max-block-size) and anchors a bottom fade-shadow to,
keyed off data-state="closed".
Leave unset for the default fully-hidden / grid-collapse behaviour. |
| forceMount | boolean | false | headless | Keep the panel mounted (and in the DOM) even when closed, so open/close
transitions can be CSS-driven. When closed under forceMount, the panel
gets aria-hidden="true" instead of the hidden attribute. Consumers
may override aria-hidden explicitly. |
Collapsible.TriggerIcon
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| children | ReactNode | — | headless | The icon to render (inline SVG or an icon component). |
Styling contract
Control frame
--primitiv-collapsible-border-color--primitiv-collapsible-border-width--primitiv-collapsible-seam-color--primitiv-collapsible-radius--primitiv-collapsible-bg--primitiv-collapsible-gap--primitiv-collapsible-trigger-padding-inline--primitiv-collapsible-trigger-gap--primitiv-collapsible-trigger-icon-size--primitiv-collapsible-trigger-icon-rotation--primitiv-collapsible-trigger-fg--primitiv-collapsible-trigger-font-family--primitiv-collapsible-trigger-font-size--primitiv-collapsible-trigger-font-weight--primitiv-collapsible-trigger-line-height--primitiv-collapsible-fade-color--primitiv-collapsible-fade-heightPanel
--primitiv-collapsible-content-fg--primitiv-collapsible-content-font-family--primitiv-collapsible-content-font-size--primitiv-collapsible-content-font-weight--primitiv-collapsible-content-line-height--primitiv-collapsible-content-padding-block--primitiv-collapsible-content-padding-inline--primitiv-collapsible-content-transition-duration--primitiv-collapsible-content-transition-easingData attributes
Collapsible
className: .primitiv-collapsible
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
data-disabled | true | false | disabled / not disabled |
Collapsible.Root
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
data-disabled | true | false | disabled / not disabled |
CollapsibleTrigger
className: .primitiv-collapsible__trigger
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
data-disabled | true | false | disabled / not disabled |
Collapsible.Trigger
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
data-disabled | true | false | disabled / not disabled |
CollapsibleContent
className: .primitiv-collapsible__content
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
data-disabled | true | false | disabled / not disabled |
data-clamped | true | false | collapsedHeight is set / collapsedHeight is unset |
Collapsible.Content
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
data-disabled | true | false | disabled / not disabled |
data-clamped | true | false | collapsedHeight is set / collapsedHeight is unset |
CollapsibleTriggerIcon
className: .primitiv-collapsible__trigger-icon
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
Collapsible.TriggerIcon
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
Accessibility
- The trigger is a real
<button>witharia-expandedandaria-controlswired to its panel, soSpaceandEntertoggle it — the native behaviour, kept rather than re-implemented. - A closed panel is
hidden(oraria-hiddenwhen force-mounted for animation), so its content never leaks to a screen reader while it looks closed. The exception iscollapsedHeight: that preview is real, readable content, so it stays in the accessibility tree even while the widget is closed. disabledis rendered asaria-disabled, not the native attribute, so the trigger stays focusable and discoverable while being inert. A disabled control that cannot be focused is one a screen-reader user never learns exists.Collapsible.TriggerIconis decorative — give the chevronaria-hidden. The expanded state is already announced througharia-expanded, so a labelled icon would say it twice.- Collapsible adds no heading of its own — if the trigger labels a section of the page, wrap or precede it with your own heading at the right level. When you have several related panels that should list as headings and share one tab stop, reach for Accordion instead.
Examples
Three dressings
variant picks the look without changing the behaviour. plain is a bare trigger row over a frameless panel — reach for it inside a container that already has a frame. card encloses trigger and panel in one bordered box, revealing a hairline seam where the gap was once open. inline styles the trigger as a text link over continuous prose — the read-more pattern below.
import { Collapsible, CollapsibleTrigger, CollapsibleTriggerIcon, CollapsibleContent } from "@/components/ui/collapsible";import { ChevronDown } from "@primitiv-ui/icons";
<Collapsible variant="plain" defaultOpen> <CollapsibleTrigger> Plain dressing <CollapsibleTriggerIcon> <ChevronDown aria-hidden="true" /> </CollapsibleTriggerIcon> </CollapsibleTrigger> <CollapsibleContent>{/* ... */}</CollapsibleContent></Collapsible>
<Collapsible variant="card" defaultOpen> <CollapsibleTrigger> Card dressing <CollapsibleTriggerIcon> <ChevronDown aria-hidden="true" /> </CollapsibleTriggerIcon> </CollapsibleTrigger> <CollapsibleContent>{/* ... */}</CollapsibleContent></Collapsible>
<Collapsible variant="inline" defaultOpen> <CollapsibleTrigger> Inline dressing <CollapsibleTriggerIcon> <ChevronDown aria-hidden="true" /> </CollapsibleTriggerIcon> </CollapsibleTrigger> <CollapsibleContent>{/* ... */}</CollapsibleContent></Collapsible>import { Collapsible } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";
// `variant`: styled-layer contract, absent from the headless primitive// — so the class names below are yours to define.
<Collapsible.Root className="collapsible--plain" defaultOpen> <Collapsible.Trigger> Plain dressing <Collapsible.TriggerIcon> <ChevronDown aria-hidden="true" /> </Collapsible.TriggerIcon> </Collapsible.Trigger> <Collapsible.Content>{/* ... */}</Collapsible.Content></Collapsible.Root>
<Collapsible.Root className="collapsible--card" defaultOpen> <Collapsible.Trigger> Card dressing <Collapsible.TriggerIcon> <ChevronDown aria-hidden="true" /> </Collapsible.TriggerIcon> </Collapsible.Trigger> <Collapsible.Content>{/* ... */}</Collapsible.Content></Collapsible.Root>
<Collapsible.Root className="collapsible--inline" defaultOpen> <Collapsible.Trigger> Inline dressing <Collapsible.TriggerIcon> <ChevronDown aria-hidden="true" /> </Collapsible.TriggerIcon> </Collapsible.Trigger> <Collapsible.Content>{/* ... */}</Collapsible.Content></Collapsible.Root>Read more (collapsedHeight)
collapsedHeight shows a clamped preview of the panel while closed instead of hiding it — the read-more pattern. Pass a number (pixels) or any CSS length. The preview is real, readable content, so it stays in the accessibility tree; a bottom fade signals there is more, and disappears once the panel is fully open. Best paired with the inline dressing.
import { Collapsible, CollapsibleContent, CollapsibleTrigger, CollapsibleTriggerIcon } from "@/components/ui/collapsible";import { ChevronDown } from "@primitiv-ui/icons";import { useState } from "react";
const [open, setOpen] = useState(false);
<Collapsible variant="inline" open={open} onOpenChange={setOpen}> <CollapsibleContent collapsedHeight={72}> {/* a passage longer than the preview height */} </CollapsibleContent> <CollapsibleTrigger> {open ? "Show less" : "Show more"} <CollapsibleTriggerIcon> <ChevronDown aria-hidden="true" /> </CollapsibleTriggerIcon> </CollapsibleTrigger></Collapsible>import { Collapsible } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";import { useState } from "react";
const [open, setOpen] = useState(false);
<Collapsible.Root className="collapsible--inline" open={open} onOpenChange={setOpen}> <Collapsible.Content collapsedHeight={72}> {/* a passage longer than the preview height */} </Collapsible.Content> <Collapsible.Trigger> {open ? "Show less" : "Show more"} <Collapsible.TriggerIcon> <ChevronDown aria-hidden="true" /> </Collapsible.TriggerIcon> </Collapsible.Trigger></Collapsible.Root>Controlled
Pass open and onOpenChange together and the parent owns the state — needed to open the panel in response to something else on the page, to keep two widgets in step, or to persist the state. Omit both (or pass defaultOpen) for the uncontrolled form, where Collapsible owns it; the two shapes are mutually exclusive, so pick one.
Panel is open.
import { Collapsible, CollapsibleTrigger, CollapsibleTriggerIcon, CollapsibleContent } from "@/components/ui/collapsible";import { ChevronDown } from "@primitiv-ui/icons";import { useState } from "react";
const [open, setOpen] = useState(true);
<Collapsible variant="card" open={open} onOpenChange={setOpen}> <CollapsibleTrigger> Release notes <CollapsibleTriggerIcon> <ChevronDown aria-hidden="true" /> </CollapsibleTriggerIcon> </CollapsibleTrigger> <CollapsibleContent>{/* ... */}</CollapsibleContent></Collapsible>import { Collapsible } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";import { useState } from "react";
const [open, setOpen] = useState(true);
<Collapsible.Root open={open} onOpenChange={setOpen}> <Collapsible.Trigger> Release notes <Collapsible.TriggerIcon> <ChevronDown aria-hidden="true" /> </Collapsible.TriggerIcon> </Collapsible.Trigger> <Collapsible.Content>{/* ... */}</Collapsible.Content></Collapsible.Root>Disabled
disabled freezes the widget: the trigger renders aria-disabled (not the native attribute, so it stays focusable and discoverable) and both click and keyboard activation are short-circuited. The state is mirrored as data-disabled onto Root, Trigger and Content for styling.
import { Collapsible, CollapsibleTrigger, CollapsibleTriggerIcon, CollapsibleContent } from "@/components/ui/collapsible";import { ChevronDown } from "@primitiv-ui/icons";
<Collapsible variant="card" disabled> <CollapsibleTrigger> Advanced settings <CollapsibleTriggerIcon> <ChevronDown aria-hidden="true" /> </CollapsibleTriggerIcon> </CollapsibleTrigger> <CollapsibleContent>{/* ... */}</CollapsibleContent></Collapsible>import { Collapsible } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";
<Collapsible.Root disabled> <Collapsible.Trigger> Advanced settings <Collapsible.TriggerIcon> <ChevronDown aria-hidden="true" /> </Collapsible.TriggerIcon> </Collapsible.Trigger> <Collapsible.Content>{/* ... */}</Collapsible.Content></Collapsible.Root>