Skip to content
KONIGI

Dashboards / Page layout / Dense small-multiple layout

1 of 5

Dense small-multiple layout

An expert wants everything on one screen and will trade legibility for it.

Updated September 10, 2026

Problem

The person who owns this system already knows what every panel means. They do not want to navigate; they want to stand back and see forty things at once, because the pattern across the forty is the diagnosis.

Solution

Fill the page with many small charts of consistent shape and let the viewer scan. Refuse to summarise, refuse to hide, and accept that each individual panel is too small to read comfortably—because reading one is not the task.

This works for the same reason small multiples work generally: identical axes and identical encoding mean the eye compares shapes without translating. The one that is wrong is wrong-shaped, and it is findable in a page of forty in about a second. That ability degrades fast if panels differ in scale, size or type, so uniformity matters more here than in any other layout.

The audience assumption is doing heavy lifting and should be stated. This layout is for someone who has the system’s normal appearance memorised. To them the page is a single instrument with forty needles. To anyone else it is noise, and no amount of labelling fixes that, because the meaning is in the deviation from a baseline they don’t have.

Colour is where the layout is usually thrown away. Forty panels each using a full categorical palette produces a page where nothing stands out. Grayscale-with-alerts is the discipline that makes density work: draw everything muted and let colour mean abnormal, so the page is grey until it isn’t.

Use when

The audience is expert and consistent, the metrics are genuinely parallel, and the diagnostic question is comparative rather than specific.

Don’t use when

The audience is mixed, or the page is anyone’s entry point. A newcomer opening a wall of charts learns that the dashboard is not for them. Give them an overview and let this be a level below it.

Trade-offs

Density trades individual legibility for comparison, which is the right trade only for the people it was built for. Query cost is real: forty panels means forty queries on every load and every refresh, which is why these pages are slow and why the loading state matters more here than anywhere. The layout is unusable on anything but a large screen, so the mobile version is either a very long scroll or nothing. And because every panel is small and nobody reads individual titles, panels that broke months ago stay on the page indefinitely.

Checklist

  • Do all panels share the same y-scale where they are meant to be compared?
  • Are panel type and size uniform enough that a deviation is shape, not styling?
  • Is the page muted by default so colour can mean abnormal?
  • How many queries does one page load fire, and how long until it settles?
  • What does the page look like at 20% loaded, and is that state honest?
  • Is there an overview above this, for people who are not the expert audience?
  • How does a broken panel announce itself when nobody reads individual titles?
  • What is the smallest screen this is usable on, and what happens below it?
  • Are panels ordered by something meaningful, or by the order they were added?
  • Who is this page for, and does anyone else ever open it?

Compare

Netdata is the pattern in its most committed form: hundreds of charts per node, per-second resolution, arranged by subsystem, on the explicit position that pre-summarising is what makes problems invisible. Grafana hosts the best-known example in the Node Exporter family of dashboards, where the density is a community convention rather than a product feature, assembled panel by panel on a 24-unit grid. Datadog discourages the shape in favour of scoped pages and its host map, so the equivalent view is reached by narrowing rather than by scanning everything. Honeycomb rejects the premise: the argument is that a wall of pre-chosen charts can only show questions someone anticipated, and the interesting failure is always in a dimension nobody put on the wall.

Small multiples is the underlying visual technique, stated properly. Panel grid is the machinery. Collapsible row is the concession this layout usually has to make to stay navigable. Grayscale with alerts is what makes density readable rather than merely dense. Wallboard mode is the sibling that optimises for distance instead of expertise.

Dense small-multiple layout anatomy Forty small charts of identical shape and scale filling the page. Thirty-nine sit in the same narrow band. One rises, and it is findable in about a second without being searched for. Forty panels, one instrument 2 1 1 IDENTICAL, EXACTLY Same axes, same size, same encoding, so the eye compares shapes without translating. Uniformity matters more here than anywhere. 2 THE WRONG SHAPE Grey until it isn't. Forty panels each using a full categorical palette is a page where nothing stands out at all. This is for someone who has the normal shape memorised. To anyone else it is noise.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Everything on one screen, legibility traded away deliberately. It works because every cell shares one y-axis, and the moment they do not the grid becomes a wall of shapes that cannot be compared.

Tokens
--muted-foreground--border--status-warn

40 panels, one scale: 1231 ms

SmallMultiples.tsxOne domain computed across every panel and stated once above the grid. Grey by default; a cell whose latest value leaves the page's band is the only coloured thing.

import { cn } from "@/lib/utils";

/**
 * Forty panels, one instrument.
 *
 * Every cell is the same size, the same encoding, and the same y-scale, which
 * is the one thing the component will not let a caller vary: the domain is
 * computed once across every series, so a rise in one cell is a rise and not
 * an autoscale. The page is grey until it isn't. A cell whose latest value
 * has left the band the rest of the page sits in is drawn in the warn colour,
 * and nothing else on the page is coloured at all.
 */
export type Panel = { key: string; values: number[] };

type Props = {
  panels: Panel[];
  unit: string;
  columns?: number;
  /** Which cells want a person. Defaults to: the latest value is outside
   *  the 5th–95th percentile of everything on the page. */
  abnormal?: (panel: Panel, all: Panel[]) => boolean;
  onOpen?: (panel: Panel) => void;
};

const percentile = (sorted: number[], p: number) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];

const outsideBand = (panel: Panel, all: Panel[]) => {
  const pool = all.flatMap((p) => p.values).sort((a, b) => a - b);
  const last = panel.values[panel.values.length - 1];
  return last < percentile(pool, 0.05) || last > percentile(pool, 0.95);
};

const W = 74, H = 30;

export function SmallMultiples({ panels, unit, columns = 8, abnormal = outsideBand, onOpen }: Props) {
  // One domain for the page. Per-cell autoscaling would make every flat line
  // look like a mountain range and the real climb look like everything else.
  const all = panels.flatMap((p) => p.values);
  const lo = Math.min(...all), hi = Math.max(...all);
  const span = hi - lo || 1;
  const n = panels[0]?.values.length ?? 1;
  const x = (i: number) => 4 + (i / (n - 1)) * (W - 8);
  const y = (v: number) => H - 3 - ((v - lo) / span) * (H - 6);

  return (
    <div>
      <p className="mb-2 text-[11px] text-muted-foreground">
        {panels.length} panels, one scale: {lo}{hi} {unit}
      </p>
      <div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
        {panels.map((panel) => {
          const flagged = abnormal(panel, panels);
          const d = panel.values.map((v, i) => `${i ? "L" : "M"}${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(" ");
          return (
            <button
              key={panel.key}
              type="button"
              onClick={() => onOpen?.(panel)}
              title={`${panel.key}: ${panel.values[panel.values.length - 1]} ${unit}`}
              className={cn(
                "rounded-[2px] border p-1 text-left",
                flagged ? "border-status-warn" : "border-border",
              )}
            >
              <span className={cn("block truncate text-[8px] leading-none", flagged ? "text-status-warn" : "text-muted-foreground")}>
                {panel.key}
              </span>
              <svg viewBox={`0 0 ${W} ${H}`} className="mt-0.5 w-full" aria-hidden="true">
                <path
                  d={d}
                  fill="none"
                  strokeWidth="1.2"
                  strokeLinejoin="round"
                  className={flagged ? "stroke-status-warn" : "stroke-muted-foreground"}
                />
              </svg>
            </button>
          );
        })}
      </div>
    </div>
  );
}

demo.tsxHow it is called: forty services, p95 in ms, one of them climbing.

import { SmallMultiples, type Panel } from "./SmallMultiples";

/**
 * p95 latency for forty services over the last seven samples. Thirty-nine sit
 * between 12 and 18ms. media has climbed to 31 and is the one coloured cell.
 */
const PANELS: Panel[] = [
  { key: "admin", values: [15, 18, 16, 18, 18, 15, 15] },
  { key: "api", values: [16, 18, 16, 13, 13, 18, 16] },
  { key: "audit", values: [15, 17, 16, 18, 13, 12, 15] },
  { key: "auth", values: [14, 13, 12, 16, 18, 17, 17] },
  { key: "billing", values: [12, 16, 15, 15, 17, 17, 16] },
  { key: "cache", values: [17, 13, 16, 12, 18, 16, 12] },
  { key: "cart", values: [12, 12, 13, 13, 16, 12, 18] },
  { key: "catalog", values: [15, 14, 15, 16, 18, 13, 16] },
  { key: "cdn", values: [13, 17, 14, 15, 12, 17, 12] },
  { key: "checkout", values: [15, 17, 14, 15, 16, 18, 12] },
  { key: "config", values: [17, 14, 14, 18, 13, 16, 14] },
  { key: "email", values: [12, 12, 16, 18, 12, 15, 12] },
  { key: "export", values: [18, 14, 15, 12, 12, 18, 17] },
  { key: "feed", values: [12, 13, 13, 12, 15, 15, 17] },
  { key: "gateway", values: [15, 15, 12, 16, 17, 13, 18] },
  { key: "geo", values: [17, 14, 14, 12, 14, 14, 12] },
  { key: "images", values: [15, 18, 12, 13, 13, 17, 12] },
  { key: "ingress", values: [12, 12, 15, 18, 15, 13, 17] },
  { key: "inventory", values: [16, 13, 15, 16, 13, 17, 18] },
  { key: "jobs", values: [13, 15, 17, 15, 12, 15, 15] },
  { key: "ledger", values: [13, 12, 14, 18, 18, 16, 14] },
  { key: "media", values: [12, 13, 13, 15, 17, 24, 31] },
  { key: "metrics", values: [18, 16, 17, 16, 12, 12, 13] },
  { key: "notify", values: [13, 15, 14, 12, 18, 16, 14] },
  { key: "oauth", values: [18, 14, 15, 12, 12, 12, 13] },
  { key: "orders", values: [16, 17, 13, 12, 16, 14, 14] },
  { key: "payments", values: [16, 15, 13, 16, 15, 18, 16] },
  { key: "profile", values: [13, 18, 15, 13, 17, 13, 14] },
  { key: "push", values: [13, 18, 16, 13, 17, 13, 13] },
  { key: "queue", values: [17, 17, 16, 13, 17, 15, 15] },
  { key: "reports", values: [16, 12, 15, 12, 12, 12, 12] },
  { key: "reviews", values: [16, 14, 13, 17, 17, 15, 14] },
  { key: "scheduler", values: [15, 18, 16, 15, 14, 16, 13] },
  { key: "search", values: [17, 12, 13, 13, 15, 16, 17] },
  { key: "sessions", values: [18, 16, 16, 12, 14, 13, 13] },
  { key: "shipping", values: [17, 12, 12, 14, 15, 15, 13] },
  { key: "sms", values: [12, 12, 13, 14, 14, 16, 16] },
  { key: "storage", values: [13, 12, 14, 13, 15, 14, 17] },
  { key: "tokens", values: [17, 17, 16, 16, 13, 16, 12] },
  { key: "webhook", values: [12, 15, 14, 17, 14, 12, 12] },
];

export default function Demo() {
  return <SmallMultiples panels={PANELS} unit="ms" onOpen={(p) => { location.hash = `#service-${p.key}`; }} />;
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Ignition

A public SCADA demo running live simulated plant data, with a runtime toggle between simple and photorealistic pump and pipe rendering—the ISA-101 argument as a setting.

Ignition — Water Treatment / Overview
Process mimic. Flat two-dimensional shapes, values placed where the instrument is, and the flow ordered by process rather than by geography. Dense small-multiple layout. Nine filters, identical four-value panels. Reading one teaches you all nine, and the two stopped ones are found by shape. Gauge and dial. Tank level as a filled vertical scale—a bounded range where the endpoints are the physical tank, which is the case the gauge survives. Grayscale with alerts. Green for running, on every unit. The opposite of grey-at-rest, and it works only while stopped is the rare case. Process mimic. Pump and pipe appearance switchable between simple and photorealistic. The whole ISA-101 argument, shipped as a setting. Semantic status color. Stopped is grey and says Stopped. Colour and word together, so the state survives a monochrome screen.
Water Treatment / Overview September 11, 2026 Ignition Perspective public demo, Water Treatment (signed out) dense · dark · desktop-web
A real process mimic with live simulated plant data, and it settles two arguments from the entry. The first: the layout is topologically faithful and geometrically loose. Raw water pumps at the left, flash mixers, seven basins, nine filters, tanks, high service pumps, clearwell—the flow reads left to right because that is the process order, not because that is where the equipment stands. The second is where it departs from ISA-101, and the departure is deliberate. Green here means running. Every basin, every flocculator, every filter that is working is green, so most of the screen is coloured and the two stopped filters are the grey ones. That inverts the high-performance HMI rule—colour reserved for abnormal—and spends the budget on the ninety percent case. It is still readable, because the abnormal state is the absence of a colour everything else has, but it only works while "stopped" is rare. The pipe colours are a third channel again, encoding which fluid is in them rather than any state at all.
  • Process mimic Flat two-dimensional shapes, values placed where the instrument is, and the flow ordered by process rather than by geography.
  • Grayscale with alerts Green for running, on every unit. The opposite of grey-at-rest, and it works only while stopped is the rare case.
  • Dense small-multiple layout Nine filters, identical four-value panels. Reading one teaches you all nine, and the two stopped ones are found by shape.
  • Semantic status color Stopped is grey and says Stopped. Colour and word together, so the state survives a monochrome screen.
  • Gauge and dial Tank level as a filled vertical scale—a bounded range where the endpoints are the physical tank, which is the case the gauge survives.
  • Process mimic Pump and pipe appearance switchable between simple and photorealistic. The whole ISA-101 argument, shipped as a setting.