Collapsible
A single region that people can show or hide: the smallest unit of progressive disclosure.
import { ChevronsUpDownIcon, FileCodeIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
const files = ["button.tsx", "dialog.tsx", "select.tsx"]
function FileRow({ name }: { name: string }) {
return (
<div className="flex h-9 items-center gap-2.5 rounded-lg border bg-card px-3 font-mono text-code text-foreground shadow-raised">
<FileCodeIcon
className="size-4 text-muted-foreground"
aria-hidden="true"
/>
{name}
</div>
)
}
export default function CollapsibleDemo() {
return (
<Collapsible className="flex w-full max-w-72 flex-col gap-2">
<div className="flex items-center justify-between gap-4 pl-1">
<span className="text-sm font-semibold text-foreground">
components/ui{" "}
<span className="font-normal text-muted-foreground">· 4 files</span>
</span>
<CollapsibleTrigger
render={
<Button variant="ghost" size="icon-sm" aria-label="Toggle files" />
}
>
<ChevronsUpDownIcon />
</CollapsibleTrigger>
</div>
<FileRow name="accordion.tsx" />
<CollapsibleContent className="flex flex-col gap-2">
{files.map((file) => (
<FileRow key={file} name={file} />
))}
</CollapsibleContent>
</Collapsible>
)
}Anatomy
dialog.tsx
select.tsx
- 1TriggerThe button that shows and hides the content. It carries
aria-expanded. - 2ContentThe region that opens and closes. Its height animates, and it is hidden from everyone while closed.
Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/collapsible.jsonUsage
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"<Collapsible>
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
Show details
</CollapsibleTrigger>
<CollapsibleContent>Anything you want to reveal.</CollapsibleContent>
</Collapsible>CollapsibleTrigger renders an unstyled <button>. Pass a prfct Button through render to give it a look — it keeps its own styles and gains the trigger's behavior and ARIA attributes.
Examples
Show more
Reveal the long tail of a summary. Control open when the trigger label should change with the state — Show all changes becomes Show less.
Faster builds, calmer logs
Incremental builds now reuse the cache across branches, cutting median build time by 38%. Logs group repeated warnings instead of printing each one.
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
export default function CollapsibleShowMore() {
const [open, setOpen] = React.useState(false)
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="flex w-full max-w-md flex-col gap-3 rounded-xl border bg-card p-5 shadow-raised"
>
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">
Release 2.4 · September 18
</span>
<h3 className="text-heading-sm text-foreground">
Faster builds, calmer logs
</h3>
</div>
<p className="text-sm leading-[1.375rem] text-muted-foreground">
Incremental builds now reuse the cache across branches, cutting median
build time by 38%. Logs group repeated warnings instead of printing each
one.
</p>
<CollapsibleContent>
<ul className="flex list-disc flex-col gap-1.5 pl-5 text-sm leading-[1.375rem] text-muted-foreground marker:text-gray-8">
<li>Preview deployments start 2× faster on cold starts.</li>
<li>The CLI prints a summary table after every deploy.</li>
<li>Environment variables can be scoped to a single branch.</li>
<li>Fixed a rare crash when a build was cancelled during upload.</li>
</ul>
</CollapsibleContent>
<CollapsibleTrigger
render={<Button variant="link" size="sm" className="w-fit" />}
>
{open ? "Show less" : "Show all changes"}
<ChevronDownIcon
data-icon="inline-end"
className="transition-transform duration-base ease-standard in-data-panel-open:rotate-180"
/>
</CollapsibleTrigger>
</Collapsible>
)
}File tree
Collapsibles nest cleanly, which makes them the building block for trees. Each folder is its own Collapsible; the chevron reads its trigger's data-panel-open attribute, so every level rotates independently.
import { ChevronRightIcon, FileIcon, FolderIcon } from "lucide-react"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
type Node = { name: string; children?: Node[] }
const tree: Node[] = [
{
name: "app",
children: [
{ name: "layout.tsx" },
{ name: "page.tsx" },
{
name: "docs",
children: [{ name: "layout.tsx" }, { name: "page.tsx" }],
},
],
},
{
name: "components",
children: [
{ name: "ui", children: [{ name: "button.tsx" }, { name: "card.tsx" }] },
],
},
{ name: "package.json" },
]
function TreeNode({ node, depth }: { node: Node; depth: number }) {
const indent = { paddingLeft: `${depth * 1.25 + 0.5}rem` }
if (!node.children) {
return (
<div
className="flex h-8 items-center gap-2 rounded-md pr-2 text-sm text-foreground"
style={indent}
>
<span className="size-3.5" aria-hidden="true" />
<FileIcon className="size-4 text-muted-foreground" aria-hidden="true" />
{node.name}
</div>
)
}
return (
<Collapsible defaultOpen={depth === 0}>
<CollapsibleTrigger
className="flex h-8 w-full items-center gap-2 rounded-md pr-2 text-left text-sm text-foreground focus-ring-inset hover:bg-accent"
style={indent}
>
<ChevronRightIcon
className="size-3.5 text-muted-foreground transition-transform duration-base ease-standard in-data-panel-open:rotate-90"
aria-hidden="true"
/>
<FolderIcon
className="size-4 text-muted-foreground"
aria-hidden="true"
/>
{node.name}
</CollapsibleTrigger>
<CollapsibleContent>
{node.children.map((child) => (
<TreeNode key={child.name} node={child} depth={depth + 1} />
))}
</CollapsibleContent>
</Collapsible>
)
}
export default function CollapsibleFileTree() {
return (
<div className="w-full max-w-64 rounded-xl border bg-card p-1.5 shadow-raised">
{tree.map((node) => (
<TreeNode key={node.name} node={node} depth={0} />
))}
</div>
)
}role="tree" with arrow-key navigation and type-ahead) is a different pattern — reach for it when people browse hundreds of nodes.Advanced settings
Tuck rarely changed options below the essentials of a form. The fields stay in the DOM order right where they apply, so the form still reads top to bottom.
import { ChevronDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
export default function CollapsibleAdvancedSettings() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<FieldGroup>
<Field>
<FieldLabel htmlFor="webhook-url">Endpoint URL</FieldLabel>
<Input
id="webhook-url"
defaultValue="https://api.example.com/hooks"
/>
</Field>
</FieldGroup>
<Collapsible className="group/advanced flex flex-col gap-4">
<CollapsibleTrigger
render={<Button variant="ghost" size="sm" className="-ml-3 w-fit" />}
>
Advanced settings
<ChevronDownIcon
data-icon="inline-end"
className="transition-transform duration-base ease-standard group-data-open/advanced:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent>
<FieldGroup className="rounded-xl border bg-surface p-4">
<Field>
<FieldLabel htmlFor="webhook-secret">Signing secret</FieldLabel>
<Input id="webhook-secret" placeholder="whsec_…" />
<FieldDescription>
Used to verify that requests come from us.
</FieldDescription>
</Field>
<Field orientation="horizontal">
<Switch id="webhook-retry" defaultChecked />
<FieldLabel htmlFor="webhook-retry" className="font-normal">
Retry failed deliveries
</FieldLabel>
</Field>
</FieldGroup>
</CollapsibleContent>
</Collapsible>
<Button className="w-fit">Save webhook</Button>
</div>
)
}Motion
CollapsibleContent animates its measured height (--collapsible-panel-height) with the base duration and standard easing, fading its content in the same beat. To disable the animation, override the transition in your copy. Reduced motion needs no work: with prefers-reduced-motion, the panel opens instantly.
Guidelines
When to use
- To hide one block of secondary content behind a clear label: details, advanced options, the rest of a long list.
- As the building block for custom disclosure patterns — file trees, sidebar sections, "show more" summaries.
When not to use
- For several related sections that should behave as a set — use an Accordion.
- For content that should float above the page — use a Popover.
- To hide content people need to complete the task at hand.
Label the outcome
The trigger should say what appears — Advanced settings, Show all changes, 3 more files — not just More. Pair text with a chevron that rotates; an icon alone doesn't say what's hidden.
Keep the trigger in place
Put the trigger before the content it controls and don't move it when the panel opens. People should be able to close what they just opened without hunting for the control.
Accessibility
The trigger is a native <button> with aria-expanded and, while open, aria-controls pointing at the panel.
| Key | Behavior |
|---|---|
Tab | Moves focus to the trigger, then into the open panel's content. |
EnterSpace | Opens or closes the panel. |
- Find in page. Set
hiddenUntilFoundonCollapsibleContentand the browser can search inside a closed panel and open it on a match. - Focus stays put. Opening a panel doesn't move focus; the next Tab lands inside it.
- Custom triggers. When you render something other than a
Buttonthroughrender, make sure it is still a real button — or passnativeButton={false}so Base UI adds button semantics.
API reference
Collapsible
Owns the open state. Renders a <div>. Accepts every prop of Base UI Collapsible.Root.
CollapsibleTrigger
The button that toggles the panel. Gets data-panel-open while open, which is handy for rotating icons. Accepts every prop of Base UI Collapsible.Trigger, including render and nativeButton.
CollapsibleContent
The panel. Animates its height by default.