Collapsible
A region that opens and closes from a button.
On this page
The watermaker has no readings while it is switched off at the breaker. Switch it on to see its output and membrane pressure.
Code
import { AlertCard } from "@bosunkit/components/alert-card";
import { Badge, type BadgeVariant } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@bosunkit/components/collapsible";
import { DescriptionList, type DescriptionListItem } from "@bosunkit/components/description-list";
import { Reading } from "@bosunkit/components/reading";
import { Droplets, Fuel, Gauge, History, type LucideIcon } from "lucide-react";
import { useId, useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The engine room in four sections: one the page opens from a coolant warning card, one open from the start, one " +
"the crew open and close, and one disabled with its reason, each trigger carrying an icon and a badge, the open " +
"sections showing their readings.";
const SECTION: CSSProperties = { borderBottom: "1px solid var(--border)" };
const BODY: CSSProperties = {
display: "flex",
flexDirection: "column",
gap: "var(--space-sm)",
margin: "var(--space-sm) 0 var(--space-md)",
padding: "var(--space-sm) var(--space-md)",
borderLeft: "2px solid var(--border)",
};
/** The readings in an open section, side by side. */
const READINGS: CSSProperties = {
display: "flex",
flexWrap: "wrap",
gap: "var(--space-lg)",
margin: "var(--space-sm) 0 var(--space-md)",
padding: "var(--space-sm) var(--space-md)",
borderLeft: "2px solid var(--border)",
};
function Heading({
icon: Icon,
name,
badge,
variant,
}: {
icon: LucideIcon;
name: string;
badge: string;
variant: BadgeVariant;
}): ReactElement {
return (
<>
<Icon size={14} aria-hidden="true" />
<span style={{ flex: 1 }}>{name}</span>
<Badge variant={variant}>{badge}</Badge>
</>
);
}
function Body({ items }: { items: readonly DescriptionListItem[] }): ReactElement {
return (
<div style={BODY}>
<DescriptionList items={items} />
</div>
);
}
export default function Showcase(): ReactElement {
const [engineOpen, setEngineOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const watermakerReasonId: string = useId();
return (
<div style={{ width: "100%", maxWidth: 520 }}>
<div style={{ marginBottom: "var(--space-sm)" }}>
<AlertCard
priority="warning"
title="Coolant temperature rising"
time="14:32"
message="94 °C at 2,200 rpm. Ease the throttle and check the raw-water strainer."
actions={
<Button
size="sm"
disabled={engineOpen}
onClick={(): void => {
setEngineOpen(true);
}}
>
Show engine
</Button>
}
/>
</div>
<Collapsible open={engineOpen} onOpenChange={setEngineOpen} style={SECTION}>
<CollapsibleTrigger>
<Heading icon={Gauge} name="Engine, Yanmar 4JH57" badge="94 °C" variant="amber" />
</CollapsibleTrigger>
<CollapsiblePanel>
<div style={READINGS}>
<Reading value={94} unit="°C" label="Coolant" source="Yanmar 4JH57" trend="rising" />
<Reading value={3.8} unit="bar" label="Oil pressure" source="Yanmar 4JH57" fractionDigits={1} />
<Reading value={2200} unit="rpm" label="Speed" source="Yanmar 4JH57" />
<Reading value={1284.6} unit="h" label="Engine hours" source="Yanmar 4JH57" fractionDigits={1} />
</div>
</CollapsiblePanel>
</Collapsible>
<Collapsible defaultOpen style={SECTION}>
<CollapsibleTrigger>
<Heading icon={Fuel} name="Tanks" badge="Fuel 62%" variant="green" />
</CollapsibleTrigger>
<CollapsiblePanel>
<div style={READINGS}>
<Reading value={124} unit="L" label="Diesel" source="Tank 1" />
<Reading value={410} unit="L" label="Fresh water" source="Tanks 2–3" />
<Reading value={35} unit="%" label="Holding tank" source="Tank 4" />
</div>
</CollapsiblePanel>
</Collapsible>
<Collapsible style={SECTION}>
<CollapsibleTrigger>
<Heading icon={History} name="Service history" badge="3 entries" variant="dim" />
</CollapsibleTrigger>
<CollapsiblePanel>
<Body
items={[
{ term: "2 August", value: "Impeller replaced in Falmouth after the raw-water flow dropped" },
{ term: "14 June", value: "Oil and filter changed at 1,050 h" },
{ term: "3 April", value: "Anodes on the shaft and saildrive renewed on the hard" },
]}
/>
</CollapsiblePanel>
</Collapsible>
<Collapsible disabled style={SECTION}>
<CollapsibleTrigger aria-describedby={watermakerReasonId}>
<Heading icon={Droplets} name="Watermaker" badge="Off" variant="dim" />
</CollapsibleTrigger>
<CollapsiblePanel>
<Body items={[{ term: "Output", value: "0 L/h" }]} />
</CollapsiblePanel>
</Collapsible>
<p id={watermakerReasonId} style={{ margin: "var(--space-sm) 0 0", fontSize: 12, color: "var(--text-dim)" }}>
The watermaker has no readings while it is switched off at the breaker. Switch it on to see its output and
membrane pressure.
</p>
</div>
);
}
A collapsible is a region that opens and closes from a button. The trigger carries aria-expanded and
data-panel-open while the region is open, and the panel carries data-open or data-closed. Closed, the panel is
not in the page at all, so it is not read out and cannot take focus. Each part is its own export, built on Base UI's
collapsible, so every part takes the props of the Base UI part it wraps.
The trigger looks like the button in its ghost emphasis: transparent until hovered, as
wide as the collapsible and at least 48 px tall, so it is a touch target. A chevron before its content points right
when the region is closed and turns a quarter, pointing down, when it opens; it is hidden from assistive technology,
since aria-expanded already says the state. The turn is a short transform on the motion tokens, and under reduced
motion the chevron turns at once. chevron={false} draws none. A trigger the app styles with its own class takes
bare, which renders only the button with neither look nor chevron, and render hands the look to the caller's own
element. The panel has no look of its own beyond leaving the page when closed.
Examples
Open
Code
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@bosunkit/components/collapsible";
import type { ReactElement } from "react";
export const description: string =
"Open from the start with defaultOpen: the engine's readings show under a trigger whose chevron has turned.";
function Row({ label, value }: { label: string; value: string }): ReactElement {
return (
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--space-md)" }}>
<span style={{ color: "var(--text-dim)" }}>{label}</span>
<span>{value}</span>
</div>
);
}
export default function Open(): ReactElement {
return (
<div style={{ width: "min(100%, 360px)" }}>
<Collapsible defaultOpen>
<CollapsibleTrigger>Engine details</CollapsibleTrigger>
<CollapsiblePanel>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "var(--space-sm)",
marginTop: "var(--space-sm)",
padding: "var(--space-sm) var(--space-md)",
borderLeft: "2px solid var(--border)",
fontFamily: "var(--font-mono)",
fontSize: 12,
}}
>
<Row label="Coolant temperature" value="82 °C" />
<Row label="Oil pressure" value="4.1 bar" />
<Row label="Engine hours" value="1,284 h" />
</div>
</CollapsiblePanel>
</Collapsible>
</div>
);
}
Closed
Code
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@bosunkit/components/collapsible";
import type { ReactElement } from "react";
export const description: string = "Closed, only the trigger shows, and the passage leg's details have left the page.";
function Row({ label, value }: { label: string; value: string }): ReactElement {
return (
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--space-md)" }}>
<span style={{ color: "var(--text-dim)" }}>{label}</span>
<span>{value}</span>
</div>
);
}
export default function Closed(): ReactElement {
return (
<div style={{ width: "min(100%, 360px)" }}>
<Collapsible>
<CollapsibleTrigger>Leg 2 details</CollapsibleTrigger>
<CollapsiblePanel>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "var(--space-sm)",
marginTop: "var(--space-sm)",
padding: "var(--space-sm) var(--space-md)",
borderLeft: "2px solid var(--border)",
fontFamily: "var(--font-mono)",
fontSize: 12,
}}
>
<Row label="Distance" value="38 nm" />
<Row label="Estimated time" value="7 h 40 min" />
<Row label="Next waypoint" value="Cap Ferret" />
</div>
</CollapsiblePanel>
</Collapsible>
</div>
);
}
Controlled
Code
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@bosunkit/components/collapsible";
import { useState, type Dispatch, type SetStateAction } from "react";
import type { ReactElement } from "react";
export const description: string =
"The parent holds the open state with open and onOpenChange, so its own label follows the region.";
function Row({ label, value }: { label: string; value: string }): ReactElement {
return (
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--space-md)" }}>
<span style={{ color: "var(--text-dim)" }}>{label}</span>
<span>{value}</span>
</div>
);
}
export default function Controlled(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
return (
<div style={{ width: "min(100%, 360px)" }}>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger>{open ? "Hide details" : "Show details"}</CollapsibleTrigger>
<CollapsiblePanel>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "var(--space-sm)",
marginTop: "var(--space-sm)",
padding: "var(--space-sm) var(--space-md)",
borderLeft: "2px solid var(--border)",
fontFamily: "var(--font-mono)",
fontSize: 12,
}}
>
<Row label="Coolant temperature" value="82 °C" />
<Row label="Oil pressure" value="4.1 bar" />
<Row label="Engine hours" value="1,284 h" />
</div>
</CollapsiblePanel>
</Collapsible>
</div>
);
}
List
- Distance
- 12 NM
- Estimated time
- 2 h 30 min
- Next waypoint
- Cap Ferret
Code
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@bosunkit/components/collapsible";
import { DescriptionList } from "@bosunkit/components/description-list";
import type { CSSProperties, ReactElement } from "react";
export const description: string =
"Several collapsibles stacked as a passage's legs: each opens on its own, and the first starts open.";
/** A leg's figures, set off from the trigger by a rule at their left. */
const BODY: CSSProperties = {
marginTop: "var(--space-sm)",
padding: "var(--space-sm) var(--space-md)",
borderLeft: "2px solid var(--border)",
};
export default function List(): ReactElement {
return (
<div style={{ width: "min(100%, 360px)" }}>
<Collapsible defaultOpen style={{ borderBottom: "1px solid var(--border)" }}>
<CollapsibleTrigger>Leg 1: Arcachon to Cap Ferret</CollapsibleTrigger>
<CollapsiblePanel>
<div style={BODY}>
<DescriptionList
items={[
{ term: "Distance", value: "12 NM" },
{ term: "Estimated time", value: "2 h 30 min" },
{ term: "Next waypoint", value: "Cap Ferret" },
]}
/>
</div>
</CollapsiblePanel>
</Collapsible>
<Collapsible style={{ borderBottom: "1px solid var(--border)" }}>
<CollapsibleTrigger>Leg 2: Cap Ferret to Biscarrosse</CollapsibleTrigger>
<CollapsiblePanel>
<div style={BODY}>
<DescriptionList
items={[
{ term: "Distance", value: "38 NM" },
{ term: "Estimated time", value: "7 h 40 min" },
{ term: "Next waypoint", value: "Biscarrosse" },
]}
/>
</div>
</CollapsiblePanel>
</Collapsible>
<Collapsible style={{ borderBottom: "1px solid var(--border)" }}>
<CollapsibleTrigger>Leg 3: Biscarrosse to Mimizan</CollapsibleTrigger>
<CollapsiblePanel>
<div style={BODY}>
<DescriptionList
items={[
{ term: "Distance", value: "21 NM" },
{ term: "Estimated time", value: "4 h 10 min" },
{ term: "Next waypoint", value: "Mimizan" },
]}
/>
</div>
</CollapsiblePanel>
</Collapsible>
</div>
);
}
Disabled
Code
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@bosunkit/components/collapsible";
import type { ReactElement } from "react";
export const description: string =
"A disabled collapsible keeps its content closed, and its trigger cannot take focus or be pressed.";
function Row({ label, value }: { label: string; value: string }): ReactElement {
return (
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--space-md)" }}>
<span style={{ color: "var(--text-dim)" }}>{label}</span>
<span>{value}</span>
</div>
);
}
export default function Disabled(): ReactElement {
return (
<div style={{ width: "min(100%, 360px)" }}>
<Collapsible disabled>
<CollapsibleTrigger>Engine details</CollapsibleTrigger>
<CollapsiblePanel>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "var(--space-sm)",
marginTop: "var(--space-sm)",
padding: "var(--space-sm) var(--space-md)",
borderLeft: "2px solid var(--border)",
fontFamily: "var(--font-mono)",
fontSize: 12,
}}
>
<Row label="Coolant temperature" value="82 °C" />
<Row label="Oil pressure" value="4.1 bar" />
<Row label="Engine hours" value="1,284 h" />
</div>
</CollapsiblePanel>
</Collapsible>
</div>
);
}
Motion
Opened content fades in while it drops a few pixels from the trigger, over the medium
motion duration, and closing content fades out over the fast duration before it leaves.
The content does not grow from zero height or shrink to it: only opacity and position move, so the rows below make
room in one step and the text is readable from the first frame. Content that starts open does not animate. Under
reduced motion it only fades; motion={false} on CollapsiblePanel shows and removes it at once.
Code
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@bosunkit/components/collapsible";
import type { ReactElement } from "react";
export const description: string =
"Two collapsibles: the first one's content fades in as it drops a few pixels and fades out as it closes; the " +
"second, with `motion={false}`, appears and leaves at once.";
function Leg({ motion, label }: { motion: boolean; label: string }): ReactElement {
return (
<Collapsible>
<CollapsibleTrigger>{label}</CollapsibleTrigger>
<CollapsiblePanel motion={motion}>
<div
style={{
padding: "var(--space-sm) var(--space-md)",
borderLeft: "2px solid var(--border)",
fontFamily: "var(--font-mono)",
fontSize: 12,
}}
>
Leg 2: 38 nm to Cap Ferret, about 7 h 40 min
</div>
</CollapsiblePanel>
</Collapsible>
);
}
export default function Motion(): ReactElement {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)", width: "min(100%, 360px)" }}>
<Leg motion label="With motion" />
<Leg motion={false} label="Without motion" />
</div>
);
}
Props
Collapsible
The root: whether the region is open, and whether it is disabled.
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | undefined | – | Whether the collapsible panel is currently open.
To render an uncontrolled collapsible, use the defaultOpen prop instead. |
| defaultOpen | boolean | undefined | – | Whether the collapsible panel is initially open.
To render a controlled collapsible, use the open prop instead. |
| onOpenChange | ((open: boolean, eventDetails: CollapsibleRootChangeEventDetails) => void) | undefined | – | Event handler called when the panel is opened or closed. |
| disabled | boolean | undefined | – | Whether the component should ignore user interaction. |
| className | string | ((state: State) => string | undefined) | undefined | – | CSS class applied to the element, or a function that returns a class based on the component's state. |
| render | React.ReactElement | ComponentRenderFn<RenderFunctionProps, State> | undefined | – | Allows you to replace the component's HTML element
with a different tag, or compose it with another component.
Accepts a ReactElement or a function that returns the element to render. |
| style | React.CSSProperties | ((state: State) => React.CSSProperties | undefined) | undefined | – | Style applied to the element, or a function that returns a style object based on the component's state. |
Also takes every attribute of ComponentProps<typeof CollapsiblePrimitive.Root>.
CollapsibleTrigger
The button that opens and closes the region.
| Prop | Type | Default | Description |
|---|---|---|---|
| chevron | boolean | undefined | true | Whether the trigger draws a chevron before its content, turned a quarter when the collapsible is open: on by
default. It is hidden from assistive technology, since aria-expanded already says the state. It has no effect
with render or bare. |
| bare | boolean | undefined | false | Renders only the trigger button, with no default look and no chevron, for a caller that styles it with
className (and draws its own icon). render does the same for the caller's own element. |
| motion | boolean | undefined | true | Whether the chevron turns over the fast duration: on by default; false turns it at once. |
| className | string | ((state: State) => string | undefined) | undefined | – | CSS class applied to the element, or a function that returns a class based on the component's state. |
| render | React.ReactElement | ComponentRenderFn<RenderFunctionProps, State> | undefined | – | Allows you to replace the component's HTML element
with a different tag, or compose it with another component.
Accepts a ReactElement or a function that returns the element to render. |
| style | React.CSSProperties | ((state: State) => React.CSSProperties | undefined) | undefined | – | Style applied to the element, or a function that returns a style object based on the component's state. |
| nativeButton | boolean | undefined | – | Whether the component renders a native <button> element when replacing it
via the render prop.
Set to false if the rendered element is not a button (for example, <div>). |
Also takes every attribute of ComponentProps<typeof CollapsiblePrimitive.Trigger>.
CollapsiblePanel
What the region shows when open, Base UI's Panel part.
| Prop | Type | Default | Description |
|---|---|---|---|
| motion | boolean | undefined | true | Whether the panel fades in, dropping a few pixels, when the crew opens it, and fades out when they close it: on by
default; it only fades under reduced motion. A panel that starts open does not animate. false opens and closes
it at once. |
| hiddenUntilFound | boolean | undefined | – | Allows the browser's built-in page search to find and expand the panel contents.
Overrides the keepMounted prop and uses hidden="until-found"
to hide the element without removing it from the DOM. |
| keepMounted | boolean | undefined | – | Whether to keep the element in the DOM while the panel is hidden.
This prop is ignored when hiddenUntilFound is used. |
| className | string | ((state: State) => string | undefined) | undefined | – | CSS class applied to the element, or a function that returns a class based on the component's state. |
| render | React.ReactElement | ComponentRenderFn<RenderFunctionProps, State> | undefined | – | Allows you to replace the component's HTML element
with a different tag, or compose it with another component.
Accepts a ReactElement or a function that returns the element to render. |
| style | React.CSSProperties | ((state: State) => React.CSSProperties | undefined) | undefined | – | Style applied to the element, or a function that returns a style object based on the component's state. |
Also takes every attribute of ComponentProps<typeof CollapsiblePrimitive.Panel>.
Related
- Button: the emphasis the trigger takes its look from.
- Accessibility: the target sizes and the accessible name a trigger needs.
- Motion: the durations a region uses to open and close.