Skip to content
Primitiv home
Framework
Consumption mode

Collapsible

stableSource Figma

A single disclosure widget pairing a trigger with a panel that expands and collapses — the single-item analogue of Accordion.

Playground

Density

Preview

A single disclosure widget: the trigger toggles this panel open and closed. Change the variant and size above to see the three dressings and the size ramp.
Size
Variant
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>

Density is set by a data-density ancestor — the Context system, not a Collapsible prop.

Installation

npx primitiv add collapsible

Import

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>

Props

Collapsible.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
defaultOpenbooleanfalseheadlessInitial open state on first render. The component owns the value thereafter. Forbidden in controlled mode — use open instead.
disabledbooleanfalseheadlessWhen 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—headlessCalled 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.
openboolean—headlessForbidden in uncontrolled mode — use defaultOpen instead. The controlled open state. Must be kept in sync by the parent via onOpenChange.
variant"plain" | "card" | "inline"plainstyledVisual style of the widget.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledControl 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.

PropTypeDefaultFromDescription
children (required)ReactNode—headlessTrigger content (label, and optionally a CollapsibleTriggerIconPropsTriggerIcon).
asChildbooleanfalseheadlessRender 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.
refRef<T>—headlessForwarded 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.

PropTypeDefaultFromDescription
children (required)ReactNode—headlessPanel content.
collapsedHeightstring | number—headlessShow 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.
forceMountbooleanfalseheadlessKeep 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.

PropTypeDefaultFromDescription
children (required)ReactNode—headlessThe 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-height

Panel

--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-easing

Data attributes

Collapsible

className: .primitiv-collapsible

AttributeValueWhen
data-stateopen | closedopen / closed
data-disabledtrue | falsedisabled / not disabled

CollapsibleTrigger

className: .primitiv-collapsible__trigger

AttributeValueWhen
data-stateopen | closedopen / closed
data-disabledtrue | falsedisabled / not disabled

CollapsibleContent

className: .primitiv-collapsible__content

AttributeValueWhen
data-stateopen | closedopen / closed
data-disabledtrue | falsedisabled / not disabled
data-clampedtrue | falsecollapsedHeight is set / collapsedHeight is unset

CollapsibleTriggerIcon

className: .primitiv-collapsible__trigger-icon

AttributeValueWhen
data-stateopen | closedopen / closed

Accessibility

  • The trigger is a real <button> with aria-expanded and aria-controls wired to its panel, so Space and Enter toggle it — the native behaviour, kept rather than re-implemented.
  • A closed panel is hidden (or aria-hidden when force-mounted for animation), so its content never leaks to a screen reader while it looks closed. The exception is collapsedHeight: that preview is real, readable content, so it stays in the accessibility tree even while the widget is closed.
  • disabled is rendered as aria-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.TriggerIcon is decorative — give the chevron aria-hidden. The expanded state is already announced through aria-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.

Density
A bare trigger row above a frameless panel — the lightest option, for use inside something that already provides a frame.
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>

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.

Density
Harmoni is the palette-generation engine underneath Primitiv — a Rust core compiled to WebAssembly that turns a single brand colour into a full, perceptually even ramp. It handles light and dark modes, neutral and soft-neutral ramps, brand-hue tinting, and an OKLCH picker for dialling in the exact anchor colours, all from one input.
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>

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.

Density

Panel is open.

The parent holds the open value and re-renders on every toggle, so the same state can drive other UI or be persisted between visits.
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>

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.

Density
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>