Skip to content
KONIGI Patterns

Dashboards / Page layout / Dense small-multiple layout

1 of 5 ›

Dense small-multiple layout

Experienced users need to compare many related charts at once.

Updated September 10, 2026

Problem

Experienced users need to scan many related measures together to spot a system-wide pattern.

Solution

Arrange many small charts with consistent sizes, scales, and visual treatment. Keep their positions stable so people can learn where to look.

Use comparable scales for comparable metrics and label exceptions. Keep routine marks quiet, reserving emphasis for conditions that need attention.

Provide a way to inspect a chart closely. Check that titles, failed queries, and missing data remain visible even in the compact layout.

Use when

The audience knows the system well and needs to compare many related chart shapes on a large screen.

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 reduces the readability of individual charts and can overwhelm newcomers. Many panels increase loading and refresh costs. Small screens need a different arrangement rather than a compressed copy.

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 supplies the repeated-chart technique. Panel grid arranges the charts. Collapsible row groups less-used detail.

Grayscale with alerts reduces color clutter. Wallboard mode optimizes an overview for distant viewing.

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: 12–31 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

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.

Ignition

A process-monitoring demo uses simulated plant data and offers simple or realistic equipment rendering.

Water Treatment / Overview September 11, 2026 Ignition Perspective public demo, Water Treatment (signed out) dense · dark · desktop-web
The demo uses simulated plant data in a process diagram arranged from incoming water through treatment and storage. Values appear beside the relevant equipment. Green means running, while stopped filters are gray and explicitly labeled. This makes normal operation colorful, unlike a display that reserves color for warnings. Repeated filter panels and clear process connections help people compare equipment.