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 box with a padded trigger built in, so a bare disclosure looks finished with no wrapper, and it grows with the content's height animation. Wrap it in a Card for a filled surface, or pass className="contents" to drop the box when nesting it inside one.
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 { IconChevronDown as ChevronDownIcon } from "@tabler/icons-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)"
// @use-when a single disclosure that opens and closes.
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.removeProperty("display") // clears the important flag too
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)
// `!important`, and it is the whole reason the close used to snap. Base
// UI sets the `hidden` ATTRIBUTE the moment the panel closes, and the UA
// stylesheet backs that with `display: none !important` — a rule that
// lives in no stylesheet you can read from `document.styleSheets`, so it
// is invisible to every check short of reading computed style in a live
// page. A plain inline `display: block` loses to it, which left the node
// laid out at ZERO height: `from` measured 0, the height went 0 to 0,
// and no transition ever ran. Measured: 0px without the flag, 98px with.
s.setProperty("display", "block", "important")
// 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 }