Skip to content
design
Components

Collapsible

A region that opens and closes from a button.

On this page
Coolant temperature rising14:32
94 °C at 2,200 rpm. Ease the throttle and check the raw-water strainer.

The watermaker has no readings while it is switched off at the breaker. Switch it on to see its output and membrane pressure.

The engine room in four sections: controlled from an alert, open from the start, uncontrolled, and disabled.
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

Coolant temperature82 °C
Oil pressure4.1 bar
Engine hours1,284 h
Open from the start with defaultOpen: the engine's readings show under a trigger whose chevron has turned.
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

Closed, only the trigger shows, and the passage leg's details have left the page.
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

The parent holds the open state with open and onOpenChange, so its own label follows the region.
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
Several collapsibles stacked as a passage's legs: each opens on its own, and the first starts open.
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

A disabled collapsible keeps its content closed, and its trigger cannot take focus or be pressed.
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.

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.
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.

PropTypeDefaultDescription
openboolean | undefined–Whether the collapsible panel is currently open. To render an uncontrolled collapsible, use the defaultOpen prop instead.
defaultOpenboolean | 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.
disabledboolean | undefined–Whether the component should ignore user interaction.
classNamestring | ((state: State) => string | undefined) | undefined–CSS class applied to the element, or a function that returns a class based on the component's state.
renderReact.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.
styleReact.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.

PropTypeDefaultDescription
chevronboolean | undefinedtrueWhether 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.
bareboolean | undefinedfalseRenders 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.
motionboolean | undefinedtrueWhether the chevron turns over the fast duration: on by default; false turns it at once.
classNamestring | ((state: State) => string | undefined) | undefined–CSS class applied to the element, or a function that returns a class based on the component's state.
renderReact.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.
styleReact.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.
nativeButtonboolean | 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.

PropTypeDefaultDescription
motionboolean | undefinedtrueWhether 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.
hiddenUntilFoundboolean | 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.
keepMountedboolean | undefined–Whether to keep the element in the DOM while the panel is hidden. This prop is ignored when hiddenUntilFound is used.
classNamestring | ((state: State) => string | undefined) | undefined–CSS class applied to the element, or a function that returns a class based on the component's state.
renderReact.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.
styleReact.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>.

  • 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.