Collapsible row
Sections that fold, with the header carrying enough state that folding costs nothing. A collapsed row hiding a firing alert is the failure case, so the count belongs on the header.
npx shadcn@latest add collapsible card
npm i lucide-react
Tokens this needs: --card, --card-foreground, --muted-foreground, --border, --status-critical
The status, chart, scale, state and direction names are an extension,
not a rename. shadcn has --destructive and five --chart-* and nothing
else in this territory.
────────────────────────────────────────────────────────────────────────
// CollapsibleRows.tsx
────────────────────────────────────────────────────────────────────────
import { useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
export type Section = {
id: string;
/** The whole design. "Advanced" hides its contents from everyone forever;
* "Disk and filesystem" is a promise specific enough to spend a click on. */
title: string;
panels: ReactNode[];
/** Alerts firing inside, by name. A folded row still says so, because a
* page that looks healthy with the unhealthy part folded away is worse
* than a long page. */
firing?: string[];
};
type Props = {
sections: Section[];
/** Which rows start open. The open set should answer "is it okay"; the
* folded rows answer why. */
defaultOpen: string[];
/** Remember it per viewer, and carry it in a shared link. */
onToggle?: (id: string, open: boolean) => void;
};
export function CollapsibleRows({ sections, defaultOpen, onToggle }: Props) {
const [open, setOpen] = useState(() => new Set(defaultOpen));
const toggle = (id: string, next: boolean) => {
const s = new Set(open);
next ? s.add(id) : s.delete(id);
setOpen(s);
onToggle?.(id, next);
};
return (
{sections.map((s) => {
const isOpen = open.has(s.id);
const firing = s.firing ?? [];
return (
toggle(s.id, o)} className="rounded-lg border bg-card">
{s.title}
{firing.length > 0 && (
{firing.length} firing
)}
{/* The count is what is behind the click, so it shows only
while the row is folded. */}
{!isOpen && (
{s.panels.length} panels
)}
{s.panels}
);
})}
);
}
────────────────────────────────────────────────────────────────────────
// demo.tsx
────────────────────────────────────────────────────────────────────────
import { Card } from "@/components/ui/card";
import { KpiTile } from "../kpi-tile/KpiTile";
import { CollapsibleRows, type Section } from "./CollapsibleRows";
/**
* Three sections. Service health is open and answers "is it okay". Disk is
* folded with its count. JVM is folded too, and two alerts inside it show on
* the header so folding it cost nothing.
*/
const NOW = new Date("2026-09-15T09:00:00Z");
const ago = (s: number) => new Date(NOW.getTime() - s * 1000);
const Stat = ({ label, value }: { label: string; value: string }) => (
{label}
{value}
);
const DISK: [string, string][] = [
["Root used", "61%"], ["/var used", "78%"], ["/data used", "43%"], ["Inodes root", "12%"],
["Read IOPS", "1.2k"], ["Write IOPS", "640"], ["Read latency", "3.1 ms"], ["Write latency", "5.4 ms"],
["Queue depth", "2"], ["Throughput", "210 MB/s"], ["I/O errors", "0"], ["Days until full", "41"],
];
const JVM: [string, string][] = [
["Heap used", "92%"], ["GC pause p99", "1.8 s"], ["Old gen", "88%"],
["Threads", "412"], ["Young GC / min", "14"], ["Full GC / hr", "3"],
];
const SECTIONS: Section[] = [
{
id: "health",
title: "Service health",
panels: [
,
,
,
],
},
{ id: "disk", title: "Disk and filesystem", panels: DISK.map(([l, v]) => ) },
{
id: "jvm",
title: "JVM and garbage collection",
firing: ["Heap used above 90%", "GC pause p99 above 1 s"],
panels: JVM.map(([l, v]) => ),
},
];
export default function Demo() {
return (
);
}