Collapsible
A single in-flow disclosure — a trigger that expands and collapses a panel of content in place, with a height animation that's reduced-motion safe.
Basic
A Collapsible.Trigger toggles a Collapsible.Panel open and closed. The panel animates its height between zero and the measured content height. By default that is all a Collapsible does — show and hide its panel, with a height animation, and nothing else; the heading, group, and twisty affordances below are all opt-in. The trigger is a neutral ghost control (composed from Button), so its hover, focus-visible, and pressed states are the family's — never the accent. A disclosure chevron is rendered after the label and rotates as the panel opens.
Noctis is the Stridge design language — a dark-by-default system of components, tokens, and an OKLCH theme engine. By default a Collapsible only shows and hides this panel, with a height animation — nothing else.
"use client";
import { Collapsible } from "@stridge/noctis";
export default function CollapsibleBasic() {
return (
<Collapsible.Root className="w-full max-w-sm">
<Collapsible.Trigger>What is Noctis?</Collapsible.Trigger>
<Collapsible.Panel>
<p className="text-small text-muted">
Noctis is the Stridge design language — a dark-by-default system of components, tokens, and an OKLCH theme
engine. By default a Collapsible only shows and hides this panel, with a height animation — nothing else.
</p>
</Collapsible.Panel>
</Collapsible.Root>
);
}
FAQ
Wrap each trigger in a Collapsible.Heading (an <h3> by default — set level for another rank) so every entry appears in the document outline and the screen-reader rotor, and stack them in a Collapsible.Group for the bordered, divider-separated card. Because panels keep their content in the DOM and opt into find-in-page by default, Ctrl + F (or ⌘ + F) finds text inside a collapsed answer and auto-expands it.
Yes — it follows the WAI-ARIA disclosure pattern. The trigger toggles on Enter and Space and exposes aria-expanded, and each open panel is a labelled region named by its trigger.
Yes. Panels keep their content in the DOM and opt into the browser's find-in-page, so Ctrl+F (or ⌘-F) matches text in a collapsed answer and auto-expands it.
Yes — the height animation and the content fade are both dropped when the user prefers reduced motion.
"use client";
import { Collapsible } from "@stridge/noctis";
const FAQS = [
{
q: "Is the Collapsible accessible?",
a: "Yes — it follows the WAI-ARIA disclosure pattern. The trigger toggles on Enter and Space and exposes aria-expanded, and each open panel is a labelled region named by its trigger.",
},
{
q: "Can find-in-page reach a collapsed answer?",
a: "Yes. Panels keep their content in the DOM and opt into the browser's find-in-page, so Ctrl+F (or ⌘-F) matches text in a collapsed answer and auto-expands it.",
},
{
q: "Does it respect reduced motion?",
a: "Yes — the height animation and the content fade are both dropped when the user prefers reduced motion.",
},
];
export default function CollapsibleFaq() {
return (
<Collapsible.Group className="w-full max-w-md">
{FAQS.map(({ q, a }) => (
<Collapsible.Root key={q}>
<Collapsible.Heading>
<Collapsible.Trigger>{q}</Collapsible.Trigger>
</Collapsible.Heading>
<Collapsible.Panel>
<p className="text-small text-muted">{a}</p>
</Collapsible.Panel>
</Collapsible.Root>
))}
</Collapsible.Group>
);
}
Collapsible.Group is presentational only — each disclosure opens and closes independently. Reach for the Accordion when only one section should be open at a time.
File tree
For sidebar and file-tree rows a heading would be wrong, and the down→up accordion chevron reads oddly. The fastest route is the built-in leading "twisty" — indicator="chevron-start" puts the chevron before the label, pointing toward the content's start edge when collapsed (mirrored in RTL) and rotating down on open.
But nothing about the trigger's look is fixed. This tree drops the built-in indicator (indicator={false}) and composes its own: a chevron that rotates and a folder glyph that swaps open/closed, both driven off Base UI's data-panel-open state, with custom monospace styling and an indent guide — all while the trigger still inherits the ghost Button's hover, focus, and press states underneath. Collapsibles nest freely.
"use client";
import { Collapsible, Icon } from "@stridge/noctis";
import { ChevronRight, File, Folder, FolderOpen } from "lucide-react";
import type { ReactNode } from "react";
/**
* A folder row, built entirely by composition. The built-in indicator is dropped (`indicator={false}`)
* and the trigger is filled with a custom chevron that rotates and a folder glyph that swaps open/closed
* — both driven by Base UI's `data-panel-open` state via `group-data-[panel-open]` — while the trigger
* still inherits the ghost Button's hover, focus, and press skin underneath. Nothing about the look is
* baked in: this is what "compose anything" means.
*/
function TreeFolder({ name, defaultOpen, children }: { name: string; defaultOpen?: boolean; children: ReactNode }) {
return (
<Collapsible.Root defaultOpen={defaultOpen}>
<Collapsible.Trigger indicator={false} className="group font-mono">
<span className="flex items-center gap-1.5">
<Icon
icon={ChevronRight}
size="sm"
className="text-muted transition-transform duration-150 group-data-[panel-open]:rotate-90 motion-reduce:transition-none"
/>
<Icon icon={Folder} size="sm" className="text-accent group-data-[panel-open]:hidden" />
<Icon icon={FolderOpen} size="sm" className="hidden text-accent group-data-[panel-open]:block" />
{name}
</span>
</Collapsible.Trigger>
<Collapsible.Panel>
{/* A custom indent guide — a hairline rule the nested rows hang off, logical so it mirrors in RTL. */}
<div className="ms-3 border-s border-border ps-2">{children}</div>
</Collapsible.Panel>
</Collapsible.Root>
);
}
/** A leaf file row — an empty chevron gutter keeps its icon aligned under the folder glyphs above. */
function TreeFile({ name }: { name: string }) {
return (
<div className="flex items-center gap-1.5 px-3 py-1.5 font-mono text-small text-muted">
<span className="inline-block w-4" aria-hidden />
<Icon icon={File} size="sm" className="opacity-70" />
{name}
</div>
);
}
export default function CollapsibleFileTree() {
return (
<div className="w-full max-w-xs">
<TreeFolder name="src" defaultOpen>
<TreeFolder name="components" defaultOpen>
<TreeFile name="button.tsx" />
<TreeFile name="collapsible.tsx" />
</TreeFolder>
<TreeFile name="index.ts" />
</TreeFolder>
</div>
);
}
Custom trigger
The built-in chevron is opt-out: pass indicator={false} to Collapsible.Trigger and compose your own leading or trailing glyph.
Pass indicator={false} to drop the built-in chevron and supply your own leading or trailing glyph instead.
"use client";
import { Collapsible, Icon } from "@stridge/noctis";
import { Plus } from "lucide-react";
export default function CollapsibleCustomTrigger() {
return (
<Collapsible.Root className="w-full max-w-sm">
<Collapsible.Trigger indicator={false}>
<span className="inline-flex items-center gap-2">
<Icon icon={Plus} size="sm" />
Show details
</span>
</Collapsible.Trigger>
<Collapsible.Panel>
<p className="text-small text-muted">
Pass <code data-allow-literal>indicator={"{false}"}</code> to drop the built-in chevron and supply your own
leading or trailing glyph instead.
</p>
</Collapsible.Panel>
</Collapsible.Root>
);
}
Controlled
Drive the open state from outside with open / onOpenChange — an external button, a keyboard shortcut, or a command-menu action can toggle the same disclosure the trigger does.
Both the external button and the trigger drive the same open state through open / onOpenChange, so they stay in sync. In a product UI you might also bind this to a keyboard shortcut or a command-menu action.
"use client";
import { Button, Collapsible } from "@stridge/noctis";
import { useState } from "react";
export default function CollapsibleControlled() {
const [open, setOpen] = useState(false);
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<Button variant="secondary" aria-expanded={open} onClick={() => setOpen((value) => !value)}>
{open ? "Hide" : "Show"} release notes
</Button>
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Collapsible.Trigger>Release notes</Collapsible.Trigger>
<Collapsible.Panel>
<p className="text-small text-muted">
Both the external button and the trigger drive the same <code data-allow-literal>open</code> state through{" "}
<code data-allow-literal>open</code> / <code data-allow-literal>onOpenChange</code>, so they stay in sync.
In a product UI you might also bind this to a keyboard shortcut or a command-menu action.
</p>
</Collapsible.Panel>
</Collapsible.Root>
</div>
);
}
Keyboard
| Key | Action |
|---|---|
| Enter / Space | On the trigger: toggle the panel open or closed. |
| Ctrl + F / ⌘ + F | The browser's find-in-page matches text inside a collapsed panel and auto-expands it (hiddenUntilFound). |
| Tab / Shift + Tab | Move through the page and into the open panel — focus is not trapped, since the disclosure is non-modal. |
Accessibility
- Heading semantics. Wrap the trigger in
Collapsible.Headingfor content disclosures (FAQs, "show more" sections) so the section surfaces in the document outline and the screen-reader rotor — the recommended shape. Omit it for sidebar / file-tree rows, where a heading would be wrong. - Labelled region. The panel renders as a
<section>withrole="group"and is named by its trigger viaaria-labelledby, so a screen-reader user inside an open panel hears its name and boundary. SetpanelRole="region"to promote a significant disclosure to a landmark (use it sparingly so the landmark list stays useful). - Find-in-page.
hiddenUntilFounddefaults totrue: closed content stays in the DOM and the browser's find reveals it. SethiddenUntilFound={false}to opt out, orkeepMounted={false}to remove closed content entirely. - Indicator. The chevron is decorative (
aria-hidden) and directional, so it mirrors under RTL; both the trailing (down→up) and leading twisty (right→down) rotations read correctly in either writing direction. - Motion. The height animation and content fade are dropped under
prefers-reduced-motion; Base UI still mounts and unmounts correctly.
Anatomy
Compose a collapsible from its parts. Collapsible.Root owns the open state (controlled via open / onOpenChange, or uncontrolled via defaultOpen) on Base UI's Collapsible.
Collapsible.Root— owns the open state and groups the parts.Collapsible.Trigger— toggles the panel; composed from the ghostButton. Renders a built-in disclosure chevron after its label by default (indicator:true/"chevron-end"trails it,"chevron-start"leads it as a twisty,falseomits it).Collapsible.Panel— the labelled content region that animates open and closed.role="group"by default (panelRole="region"to promote it); kept mounted and findable by default (hiddenUntilFound,keepMounted).Collapsible.Heading(optional) — wraps the trigger in an<h3>(setlevel) for the document outline.Collapsible.Group(optional) — a presentational container that stacks several collapsibles into one bordered, divider-separated card; it does not coordinate open state.
Every rendered part carries a data-slot (noctis-collapsible, noctis-collapsible-trigger, noctis-collapsible-panel, noctis-collapsible-heading, noctis-collapsible-group) for host-side styling — pair it with the Base UI Collapsible state attributes (data-open / data-closed on the panel, data-panel-open on the trigger, data-starting-style / data-ending-style on the panel during the animation, and data-disabled).
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every collapsible in that region retunes — e.g. .faq { --noctis-collapsible-panel-padding-block: 1rem; } loosens the disclosed content beneath it, and --noctis-collapsible-panel-transition-duration tunes the open/close speed. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that forward to Base UI's Collapsible list just the props they pass through. Expand a row for the full type and description.