Skip to content
KONIGI Patterns

Dashboards / Managing screen space / Collapsible row

1 of 6 ›

Collapsible row

People need a clear summary with less-used panels available when needed.

Updated September 10, 2026

Problem

A dashboard contains useful detail that most people need only occasionally. Showing every panel makes the everyday summary harder to find.

Solution

Group panels into clearly named sections that can expand and collapse. Keep the main summary open and make secondary sections easy to identify from their titles.

Remember each person’s expanded sections. Provide Expand all when useful, and make search reveal matching content inside closed sections.

Keep warnings visible even when their panels are collapsed. A closed section must not make a dashboard appear healthy when it contains a problem.

Use when

Sections serve different tasks or audiences, and the visible summary can answer common questions on its own.

Don’t use when

Every panel matters every time, which means you have a shorter dashboard than you think you do. Also don’t use it to make an overloaded page feel tidy; the load is still there and now it is hidden.

Trade-offs

Hidden panels are easier to overlook and may receive less maintenance. Vague section names add unnecessary searching. Different expansion states can also make it harder for colleagues to discuss the same view.

Checklist

  • Does the section title say what is inside, specifically enough to be worth a click?
  • Which rows are collapsed by default, and does the open set answer “is it okay”?
  • Is the collapse state remembered, and is it per-viewer or per-dashboard?
  • Does a panel in a collapsed row still run its query, and should it?
  • If something inside a collapsed row is alerting, does the row say so?
  • Does browser find, or the product’s search, reach collapsed panels?
  • Is there an expand-all, and does everyone use it immediately?
  • On a wallboard, does anything start collapsed that nobody can expand?
  • Does a shared link carry the expansion state?
  • Would splitting into separate dashboards serve these audiences better?

Compare

Grafana makes rows first-class objects with their own titles, collapse state and repeat-by-variable behaviour, so a row can expand into one section per instance, which is the pattern doing real structural work rather than only tidying. Datadog groups panels into named sections that collapse, and surfaces alert status on the group header so a folded section can still say something is wrong. AWS CloudWatch leans on separate dashboards instead of sections, which sidesteps the hiding problem and replaces it with a navigation problem. Netdata builds the entire page as collapsible sections driven by a menu, and because the section list doubles as the navigation the titles are load-bearing in a way most implementations never make them.

Panel grid arranges the panels within a section. Detail on demand expands an individual panel. Dense small-multiple layout keeps more charts visible at once.

Semantic grouping determines useful sections. Multi-page dashboard separates larger areas into pages.

Collapsible row anatomy A page of three named sections. The first is open and answers whether things are okay; the two below are folded, each naming what it holds and how many panels. One folded row carries an alert count, because a page that looks healthy with the unhealthy part folded away is worse than a long page. Anatomy Service health Disk and filesystem 12 panels JVM and garbage collection 2 firing 1 2 3 1 THE DISCLOSURE Open for the section that says whether things are okay; folded for the ones that say why. 2 THE TITLE The whole design, and usually an afterthought. Readers treat clicks like currency. 3 WHAT'S FIRING INSIDE A page that looks healthy because the unhealthy part is folded away is worse than a long page. Same twelve panels, two titles Advanced Hidden from everyone, forever Disk and filesystem A promise worth a click
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

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.

shadcn
npx shadcn@latest add collapsible card
npm
lucide-react
Tokens
--card--card-foreground--muted-foreground--border--status-critical

Checkout success

99.4%

+0.3%Last 24 hours

p95 latency

412 ms

-2.1%Last 24 hours

Error budget left

71%

-4.0%This quarter

CollapsibleRows.tsxNamed sections that fold. A folded header carries its panel count, and the alerts firing inside it by name.

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 (
    <div className="flex flex-col gap-2">
      {sections.map((s) => {
        const isOpen = open.has(s.id);
        const firing = s.firing ?? [];
        return (
          <Collapsible key={s.id} open={isOpen} onOpenChange={(o) => toggle(s.id, o)} className="rounded-lg border bg-card">
            <CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-card-foreground">
              <ChevronRight className={cn("size-3.5 shrink-0 text-muted-foreground transition-transform", isOpen && "rotate-90")} />
              <span className="truncate">{s.title}</span>
              <span className="ml-auto flex shrink-0 items-center gap-2">
                {firing.length > 0 && (
                  <span
                    className="inline-flex items-center gap-1.5 rounded-full bg-status-critical/10 px-2 py-0.5 text-[11px] text-status-critical"
                    title={firing.join(", ")}
                  >
                    <span className="size-1.5 rounded-full bg-status-critical" aria-hidden="true" />
                    {firing.length} firing
                  </span>
                )}
                {/* The count is what is behind the click, so it shows only
                    while the row is folded. */}
                {!isOpen && (
                  <span className="text-[11px] tabular-nums text-muted-foreground">{s.panels.length} panels</span>
                )}
              </span>
            </CollapsibleTrigger>
            <CollapsibleContent>
              <div className="grid grid-cols-3 gap-2 border-t p-3">{s.panels}</div>
            </CollapsibleContent>
          </Collapsible>
        );
      })}
    </div>
  );
}

demo.tsxHow it is called: health open, disk and JVM folded, two alerts showing through the JVM header.

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 }) => (
  <Card className="p-3 shadow-none">
    <p className="text-xs text-muted-foreground">{label}</p>
    <p className="mt-1 text-lg font-semibold tabular-nums">{value}</p>
  </Card>
);

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: [
      <KpiTile key="ok" label="Checkout success" value="99.4%" delta={0.3} window="Last 24 hours" lastArrival={ago(40)} expectedEveryMs={60_000} now={NOW} />,
      <KpiTile key="p95" label="p95 latency" value="412 ms" delta={-2.1} polarity="lower-is-better" window="Last 24 hours" lastArrival={ago(40)} expectedEveryMs={60_000} now={NOW} />,
      <KpiTile key="budget" label="Error budget left" value="71%" delta={-4.0} window="This quarter" lastArrival={ago(300)} expectedEveryMs={60_000} now={NOW} />,
    ],
  },
  { id: "disk", title: "Disk and filesystem", panels: DISK.map(([l, v]) => <Stat key={l} label={l} value={v} />) },
  {
    id: "jvm",
    title: "JVM and garbage collection",
    firing: ["Heap used above 90%", "GC pause p99 above 1 s"],
    panels: JVM.map(([l, v]) => <Stat key={l} label={l} value={v} />),
  },
];

export default function Demo() {
  return (
    <div className="max-w-[560px]">
      <CollapsibleRows sections={SECTIONS} defaultOpen={["health"]} />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Netdata

A large collection of system charts with per-chart controls and anomaly-rate views.

Metrics / System › Compute › CPU September 10, 2026 Netdata Agent (public registry node, signed out) dense · dark · desktop-web
This section follows several nested levels of the generated metric hierarchy. Leading dashes indicate depth, while a breadcrumb identifies the current CPU section. Each chart includes its own grouping and aggregation controls. The Anomaly Rate option is visible but switched off, so this capture demonstrates the control rather than the resulting anomaly display.