Skip to content
KONIGI

Dashboards / Visual representation / Small multiples

17 of 22

Small multiples

Many series would tangle on one chart; the viewer needs them on identical axes side by side.

Updated September 10, 2026

Problem

Twelve services, one metric each. On one chart that is twelve lines and a legend nobody reads. On twelve dashboards it is twelve page loads and no comparison at all.

Solution

Repeat the same chart once per category, identical in every respect except the data. Same axes, same scale, same size, same colour, arranged in a grid. The viewer learns to read one chart and then reads all twelve for free.

The mechanism is worth stating precisely because it explains every rule that follows. Comparison becomes shape recognition. Once the encoding is constant, the eye does not decode each panel independently; it registers the one whose outline differs. That is a fundamentally cheaper operation than tracing a line through a tangle and matching it to a legend colour.

Everything that breaks the pattern breaks that mechanism.

Independent y-scales are the main offender. Autoscaling each panel to its own data makes every chart look equally eventful, and a service idling between 2 and 3 requests per second draws the same dramatic peaks as one saturating. If panels are meant to be compared, the scale must be shared, and if it genuinely cannot be, the panels should be visibly separated so nobody compares them by accident.

Order should mean something. Alphabetical is a default, not a decision. Sorting by magnitude, by deviation, or by a stable grouping puts the interesting panel where the eye lands.

Consistency includes colour. If each panel colours its series differently, the viewer has to check the legend twelve times and the free comparison is gone.

Grafana’s repeat-by-variable feature is the pattern automated: define one panel, repeat it across a template variable’s values, and the grid maintains itself as instances come and go. That last part matters more than the convenience, because a hand-built grid of twelve goes stale the moment there are thirteen.

Use when

Categories are parallel and comparable, the count is more than about four and less than a few dozen, and the question is which one differs.

Don’t use when

The categories are not comparable, or the count is very small—three panels is three panels, not a pattern. Above a few dozen the individual charts stop being readable and you are into dense small-multiple layout, which is a different design with a different audience.

Trade-offs

Small multiples spend a lot of space to make one comparison easy, and each panel is necessarily small, so within-panel detail is lost. They handle only one metric at a time: comparing twelve services on latency and errors means two grids or a compromise. Query cost multiplies by the panel count. And the shared-scale requirement fights the data whenever one category dwarfs the others, at which point either the small ones flatline or the large one is clipped.

Checklist

  • Do all panels share a y-scale, and if not, is that visually obvious?
  • Is the panel order meaningful, or alphabetical by default?
  • Is the encoding identical across panels, including colour?
  • Is each panel labelled clearly enough to identify without hunting?
  • How many panels before individual charts stop being readable?
  • Is the grid generated from the data, so new categories appear automatically?
  • What happens when one category dwarfs the rest?
  • How many queries does the grid fire, and how does it load?
  • Is there a way to see one panel larger without leaving the comparison?
  • Would a single chart with a good legend actually serve better here?

Compare

Grafana automates the pattern with panel repeat over a template variable, so the grid tracks the data rather than a snapshot of it, and leaves the shared-scale decision to whoever remembers to set it. Netdata produces small multiples as a by-product of its per-dimension charting, at a density that pushes past comparison into the dense-layout territory covered separately. Datadog offers a split-graph mode that facets a single graph by tag, which is the same idea reached from the chart rather than from the dashboard. Observable and ggplot2 are where faceting is a first-class grammar concept rather than a dashboard feature, and their default of a shared scale is a quietly better decision than most dashboard tools make.

Dense small-multiple layout is this pattern pushed past legibility on purpose, for an expert audience. Time series is usually the chart being repeated. Legend and series toggle is the alternative when the series stay on one chart. Template variable is what generates the grid. Panel grid is the layout system underneath.

Small multiples anatomy Twelve services drawn twice. On a shared scale, eleven sit flat and the saturating one is findable in a second. Autoscaled to its own data, every panel looks equally eventful and the comparison the grid existed for is gone. Twelve services, two scales checkout search cart user feed auth media billing email push jobs sync One shared scale checkout search cart user feed auth media billing email push jobs sync Each to its own 1 2 1 COMPARISON BECOMES SHAPE Once the encoding is constant the eye stops decoding each panel and simply registers the one whose outline differs. That is a much cheaper operation than tracing a tangle. 2 INDEPENDENT SCALES The main offender. A service idling between two and three requests a second draws the same dramatic peaks as one saturating. Order should mean something too. Alphabetical is a default, not a decision — sort by magnitude or by deviation and the interesting panel lands where the eye already is. If the scale genuinely cannot be shared, separate the panels visibly, so nobody compares them by accident.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

One chart repeated across a dimension, which only works if every panel shares a y-scale. Free scales make nine panels look alike when one of them is ten times the others.

Tokens
--card--muted-foreground--border--ring--chart-1

0–21 5xx/s, one scale for every panel

SmallMultiples.tsxOne domain computed from every series and applied to every panel, one stroke token, and an order prop that has no alphabetical option.

export type Series = { key: string; label: string; values: number[] };

/** Alphabetical is a default, not a decision, so it is not on the list.
 *  `given` means the caller has already ordered them by something. */
export type Order = "magnitude" | "deviation" | "given";

type Props = {
  series: Series[];
  /** The unit, printed once with the shared range. */
  unit: string;
  order: Order;
  /** See one larger without leaving the grid. */
  onSelect?: (key: string) => void;
  columns?: number;
};

const W = 132, H = 40, PAD = 4;

const mean = (v: number[]) => v.reduce((a, b) => a + b, 0) / v.length;
const sort = (series: Series[], order: Order) => {
  if (order === "given") return series;
  const score = order === "magnitude"
    ? (s: Series) => Math.max(...s.values)
    : (s: Series) => Math.max(...s.values.map((v) => Math.abs(v - mean(s.values))));
  return [...series].sort((a, b) => score(b) - score(a));
};

/**
 * One chart, repeated. Every panel is drawn against the same domain, computed
 * here from all of them, so a panel cannot autoscale itself into looking
 * eventful. The stroke is the same token in every cell for the same reason.
 */
export function SmallMultiples({ series, unit, order, onSelect, columns = 4 }: Props) {
  const max = Math.max(1, ...series.flatMap((s) => s.values));
  const x = (i: number, n: number) => PAD + (i / (n - 1)) * (W - PAD * 2);
  const y = (v: number) => H - PAD - (v / max) * (H - PAD * 2);

  return (
    <div>
      <p className="mb-2 text-[10px] text-muted-foreground">
        0–{max} {unit}, one scale for every panel
      </p>
      <div className="grid gap-x-2 gap-y-3" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
        {sort(series, order).map((s) => {
          const d = s.values.map((v, i) => `${i ? "L" : "M"}${x(i, s.values.length).toFixed(1)} ${y(v).toFixed(1)}`).join(" ");
          const last = s.values[s.values.length - 1];
          return (
            <button
              key={s.key}
              type="button"
              onClick={() => onSelect?.(s.key)}
              className="rounded text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
              aria-label={`${s.label}, ${last} ${unit}, open larger`}
            >
              <p className="truncate text-[9px] text-muted-foreground">{s.label}</p>
              <svg viewBox={`0 0 ${W} ${H}`} className="mt-0.5 w-full rounded border bg-card" aria-hidden="true">
                <path d={d} fill="none" className="stroke-chart-1" strokeWidth="1.5" strokeLinejoin="round" />
              </svg>
            </button>
          );
        })}
      </div>
    </div>
  );
}

demo.tsxHow it is called: twelve services, 5xx per second, in volume order. Auth is the one with a shape.

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

/**
 * Twelve services, 5xx responses per second over the last nine minutes,
 * already ordered by request volume. Eleven idle between zero and four.
 * Auth is climbing to twenty-one, and on the shared scale it is the only
 * panel with a shape.
 */
const SERVICES: Series[] = [
  { key: "checkout", label: "checkout", values: [1, 1, 2, 1, 1, 1, 1, 1, 1] },
  { key: "search", label: "search", values: [2, 2, 2, 2, 3, 2, 2, 3, 3] },
  { key: "cart", label: "cart", values: [1, 1, 2, 0, 1, 0, 0, 0, 0] },
  { key: "user", label: "user", values: [3, 1, 2, 2, 2, 2, 1, 1, 1] },
  { key: "feed", label: "feed", values: [1, 0, 0, 0, 0, 0, 1, 1, 0] },
  { key: "auth", label: "auth", values: [3, 3, 4, 3, 7, 10, 14, 18, 21] },
  { key: "media", label: "media", values: [1, 2, 1, 2, 2, 2, 2, 1, 2] },
  { key: "billing", label: "billing", values: [1, 1, 1, 2, 2, 1, 1, 0, 1] },
  { key: "email", label: "email", values: [3, 3, 3, 2, 2, 3, 1, 2, 2] },
  { key: "push", label: "push", values: [0, 0, 1, 0, 0, 0, 1, 0, 0] },
  { key: "jobs", label: "jobs", values: [3, 4, 3, 4, 3, 3, 3, 4, 4] },
  { key: "sync", label: "sync", values: [1, 1, 1, 1, 1, 2, 1, 1, 1] },
];

export default function Demo() {
  return (
    <div className="w-[360px] rounded-lg border bg-card p-4">
      <SmallMultiples series={SERVICES} unit="5xx/s" order="given" onSelect={() => {}} />
    </div>
  );
}
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.

Tableau Public

Thousands of dashboards made by people who are not designers, published without a review step. The best available sample of what the pattern language looks like in the wild.

Emergency Department / Clinical Dashboard September 11, 2026 Tableau Public embed view; emergency department patient flow workbook dense · light · desktop-web
Worth recording partly for what it is not. I went looking for a bed map, and what the public web has instead is this: analysis about an emergency department rather than the board the department actually runs on. There is no row per bed, no occupancy, no waiting-for column. Track boards live inside the patient record and never leave it, which is why that pattern has no example here and probably never will. What this does have is the punch card the calendar-heatmap entry names as the better answer for anything with a daily rather than weekly shape: weekday down the side, hour of day across the top, and the busy band from roughly ten to twenty-one is legible instantly without anyone labelling it. Its ramp is the problem—a blue-to-orange diverging scale on a patient count, which has no meaningful centre, so the midpoint sits wherever the data happened to average. The treemap beneath it degenerates into a mosaic of unlabelled slivers about a third of the way across.
  • Calendar heatmap The punch-card variant: hour of day against weekday. The busy band reads in a second, from the layout alone.
  • Small multiples Twelve month panels on one shared y-axis, so the seasonal fall from 265 in May to 52 in December is comparable across all of them.
  • Sequential and diverging scales A diverging blue-orange ramp on wait time, which has no meaningful centre, so the midpoint is wherever the mean fell.
  • Target and progress Each unit against a median reference line, green below and red above. A target marker doing the work of a threshold.
  • Filter bar One dropdown, full width, showing its selected value rather than a count. Everything below is scoped to it.
Show 2 more examples Hide the rest

Grafana

The reference implementation for panel grids, template variables, and stat panels; most other tools are defined by how they differ from it.

Grafana SLO / SLO Overview September 10, 2026 Grafana Play (signed out; no version string exposed) dense · dark · desktop-web
Twenty-eight objectives, each a row carrying a 28-day indicator, the budget remaining, and a sparkline. The budget column is where it comes apart. Four rows read −1900%, −1815%, −1093% and −463%. A budget is the amount of failure an objective permits, so it bottoms out at −100% and everything past that is the display reporting how wrong the target was rather than how broken the service is. Those four sit in the same column, in the same type, as a row reading 99.8%. Two rows above them an objective reports "No data" in the same red, which is a third thing again and looks like the second. Meanwhile eleven rows sit at exactly 100.0% with a full budget—objectives that cannot fire. The sparkline column is scaled per row, so one row's axis runs 0 to 200% and its neighbour's runs 96 to 100, and the shapes are not comparable down the page even though the layout invites exactly that.
  • Metric targets The budget column: the derived quantity that turns a target from a binary into a rate.
  • Target and progress Budget left, −1900%. Past −100% the number is measuring the objective, not the service.
  • Error and stale state No data, in the same red as a breach. A third state wearing the second one's colour.
  • Small multiples A column of sparklines, each on its own axis. One runs 0–200%, the next 96–100%.
  • Header KPI strip Five tiles counting targets, objectives and series. None of them says whether any of it is met.
Grafana Heatmaps September 9, 2026 Grafana Play (signed out; no version string exposed) dense · dark · desktop-web
Grafana's own teaching dashboard for the heatmap panel, and it teaches well because it holds the data constant and varies exactly one thing. The top-left panel is the source series. The eight heatmaps are all those same numbers under different y-bucket scales: linear, log2, log10, each with and without a split, two of them clamped to a 700-15k range. Read across the grid and the argument makes itself. The linear version crushes almost everything into a band near the bottom of the axis; the log versions spread the same distribution across the full height and the dense region moves. Same data, different readings, and nothing on any single panel tells you which bucketing you're looking at except the title someone remembered to write.
  • Time series The source series. Every heatmap on the page is built from this.
  • Heatmap Linear y buckets, which is the default and the one that hides the low end.
  • Small multiples Nine panels, one variable changed. The comparison is the content.
  • Sequential and diverging scales Every panel carries its own ramp legend, 1 to 30, so the colour is readable per panel but not across them.