Collapsible
A disclosure that expands and collapses a section of content.
Installation
$
Usage
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"Examples
Default
Collapsible ships its own surface: a bordered, clipped box with a padded trigger built in, so a bare disclosure looks finished with no wrapper. The box grows with the content's own height animation. For a filled/shadowed surface, wrap it in a Card; when nesting it headlessly inside another surface, pass className="contents" to drop the box.
The component source, its dependencies, and the design tokens it relies on. Everything installs directly into your project as editable code.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | - | Controlled open state. Pair with onOpenChange. |
| defaultOpen | boolean | false | Whether the disclosure starts open when uncontrolled. |
| onOpenChange | (open: boolean) => void | - | Called when the open state changes. |
| disabled | boolean | false | Disables the disclosure; the trigger dims and stops responding. |
"use client"
import * as React from "react"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/registry/lib/utils"
// Disclosure reveal timings. Exit is faster than the entrance so closing feels
// crisp, not sluggish; both stay under the 300ms UI ceiling.
const OPEN_MS = 200
const CLOSE_MS = 150
// The divider fades out fast and front-loaded on close (well under CLOSE_MS) so a
// 1px line never sits crisp while the panel collapses behind it.
const BORDER_MS = 75
// A strong ease-out - punchy start, smooth settle - mirrors the site's
// --ease-out-quart token. Inlined so the registry item stays self-contained.
const EASE_OUT = "cubic-bezier(0.165, 0.84, 0.44, 1)"
function Collapsible({
className,
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
// Ships its own surface so a bare disclosure looks finished: a bordered,
// clipped box the padded trigger sits flush against. When Collapsible is used
// headlessly to wrap content already inside another surface (e.g. a Card),
// pass `className="contents"` to drop the box (border/overflow/radius no-op)
// while keeping the open/close context. When `render`-ing onto a Card, pass
// `border-0` on the Card: Card's edge is now a `shadow-ring` hairline, not a
// `border`, so this `border` won't dedupe against it and you'd get a double edge.
return (
<CollapsiblePrimitive.Root
data-slot="collapsible"
className={cn(
"overflow-hidden rounded-lg border border-border bg-card",
className
)}
{...props}
/>
)
}
function CollapsibleTrigger({
className,
children,
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Trigger>) {
return (
<CollapsiblePrimitive.Trigger
data-slot="collapsible-trigger"
className={cn(
"group flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-3 text-sm font-medium text-muted-foreground ring-inset transition-colors ease-out outline-none hover:bg-muted hover:text-foreground focus-visible:ring-ring-focus focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 pointer-coarse:min-h-9",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="size-4 shrink-0 transition-transform motion-reduce:transition-none duration-200 ease-out group-data-[panel-open]:rotate-180" />
</CollapsiblePrimitive.Trigger>
)
}
function CollapsibleContent({
className,
children,
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Panel>) {
const ref = React.useRef<HTMLDivElement>(null)
// Single clean reveal, driven in JS by measuring the content's own height -
// the same proven mechanism the kit's animate-height Card uses. We deliberately
// DON'T lean on Base UI's height keyframe: with a measured var it hitches on
// open (the keyframe captures --collapsible-panel-height before Base UI sets
// it), and Base UI only defers its exit for a *keyframe*, not a JS transition.
//
// Instead we pass `keepMounted` so the panel node never unmounts (Base UI then
// treats us as animation-type "none" and just toggles the `hidden` attribute),
// and we own both `height` and `display`: on each open/close we pin the current
// height, force a reflow, then transition to the target (measured height, or 0).
// Forcing `display: block` before measuring overrides Base UI's `hidden` so the
// content stays laid out through a close, and it also survives an interrupted
// toggle (we read the live height each time, so mid-flight reversals are smooth).
// On settle we release the inline styles: open -> natural `auto` height; closed
// -> `hidden` resumes (display:none), so collapsed content leaves the a11y tree.
React.useEffect(() => {
const node = ref.current
if (!node) return
// A panel that is already open (no reveal to clip) must not keep the clip:
// it would shear the focus rings of any field inside. Covers both an
// open-on-mount disclosure and the reduced-motion path below, neither of
// which ever runs a transition that could release it.
const syncOverflow = () => {
node.style.overflow = node.hasAttribute("data-open") ? "visible" : ""
}
syncOverflow()
// Reduced motion: let Base UI show/hide instantly via `hidden`, no animation.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const reducedObserver = new MutationObserver(syncOverflow)
reducedObserver.observe(node, {
attributes: true,
attributeFilter: ["data-open", "data-closed", "hidden"],
})
return () => reducedObserver.disconnect()
}
let wasOpen = node.hasAttribute("data-open")
const settle = (e: TransitionEvent) => {
if (e.target !== node || e.propertyName !== "height") return
node.style.transition = ""
node.style.height = ""
node.style.display = ""
node.style.borderTopColor = ""
// The clip is only needed *during* the height reveal. Once an open panel
// settles, release it so focus rings on the fields inside aren't sheared
// at their edges (every control rings 1px outside its border box).
node.style.overflow = node.hasAttribute("data-open") ? "visible" : ""
}
const animate = (open: boolean) => {
const s = node.style
s.overflow = "" // re-arm the clip for the reveal (settle releases it again)
s.display = "block" // lay out through the animation, overriding `hidden`
// Live start height: 0 for a fresh open, otherwise the current rendered
// (possibly mid-transition) height so reversals don't jump.
const from = s.height ? node.offsetHeight : open ? 0 : node.offsetHeight
s.height = "auto"
const to = open ? node.offsetHeight : 0
s.transition = "none"
s.height = `${from}px`
void node.getBoundingClientRect() // reflow so the next assignment animates
// Both directions ease-out (the panel is entering/leaving), but the exit is
// quicker than the entrance - a disclosure should snap shut with less
// ceremony than it opens (Emil Kowalski: exits can be faster than entrances).
if (open) {
s.borderTopColor = "" // divider is crisp on the way in, no fade
s.transition = `height ${OPEN_MS}ms ${EASE_OUT}`
s.height = `${to}px`
} else {
// Fade any divider border out fast alongside the collapse (a no-op when
// the panel has no border), then run the shorter height exit.
s.transition = `height ${CLOSE_MS}ms ${EASE_OUT}, border-top-color ${BORDER_MS}ms ease-out`
s.height = `${to}px`
s.borderTopColor = "transparent"
}
}
const observer = new MutationObserver(() => {
const isOpen = node.hasAttribute("data-open")
if (isOpen === wasOpen) return
wasOpen = isOpen
animate(isOpen)
})
observer.observe(node, {
attributes: true,
attributeFilter: ["data-open", "data-closed", "hidden"],
})
node.addEventListener("transitionend", settle)
return () => {
observer.disconnect()
node.removeEventListener("transitionend", settle)
}
}, [])
// overflow-hidden clips the content during the height reveal. `className` lands
// on the panel (e.g. a divider `border-t` the demos add).
return (
<CollapsiblePrimitive.Panel
ref={ref}
keepMounted
data-slot="collapsible-content"
className={cn("overflow-hidden", className)}
{...props}
>
{children}
</CollapsiblePrimitive.Panel>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }