Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Small multiples

‹ 17 of 22 ›

Small multiples

People need to compare separate charts using the same scales and layout.

Updated September 10, 2026

Problem

People need to compare several categories without untangling overlapping lines or opening separate dashboards.

Solution

Repeat the same chart for each category, using consistent size, axes, and visual treatment. Use shared scales when people need to compare values directly.

Arrange charts in an order that supports the task, such as a stable grouping or a clearly labeled ranking. If scales differ, make that difference obvious rather than inviting a false comparison.

Keep the set up to date as categories appear or disappear. Provide access to detail without making every small chart carry it.

Use when

Categories are comparable and people need to spot differences across a manageable set.

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

Repeated charts take space and leave less room for detail in each one. A dominant category can flatten the rest on a shared scale. More panels also increase the amount of data to load and refresh.

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 extends the approach for expert scanning. Time series is often the repeated chart. Legend and series toggle is an alternative within one chart.

Template variable can generate the repeated set. Panel grid arranges it.

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

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.

Tableau Public

Published workbooks show how individual authors arrange charts and interactions in Tableau.

Emergency Department / Clinical Dashboard September 11, 2026 Tableau Public embed view; emergency department patient flow workbook dense · light · desktop-web
This workbook analyzes emergency-department activity rather than showing a live bed board. Its hour-by-weekday grid makes recurring busy periods easy to locate. Monthly small multiples support seasonal comparison on a shared scale. The color ramps need careful labeling: a diverging scale on a count or duration needs a meaningful center, not an unexplained midpoint.
Show 2 more examples Hide the rest

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Grafana SLO / SLO Overview September 10, 2026 Grafana Play (signed out; no version string exposed) dense · dark · desktop-web
The overview lists twenty-eight objectives with an indicator, remaining budget, and sparkline for each. The budget column combines large negative percentages with positive values, but gives little explanation of how to read them. No data uses the same red as breached objectives, making missing information harder to distinguish from a failed target. The sparklines also use different scales, so their shapes cannot be compared directly across rows.
  • Metric targets The budget column adds context to each objective’s result.
  • Target and progress Large negative budget values need a clear explanation of the calculation.
  • Error and stale state No data uses the same red treatment as a breach.
  • Small multiples Sparkline scales differ between rows, limiting direct comparison.
  • Header KPI strip The top tiles count targets, objectives, and series rather than showing whether goals are met.
Grafana Heatmaps September 9, 2026 Grafana Play (signed out; no version string exposed) dense · dark · desktop-web
This teaching dashboard shows the source series and several heatmaps built from it. Different value scales and bucket settings change the apparent distribution. The linear view concentrates much of the data near the bottom; logarithmic views spread it across more of the chart. Titles identify the settings, making the grid useful for comparing their effects.
  • Time series The source series supplies the data for the heatmaps.
  • Heatmap Linear value intervals concentrate much of this distribution near the bottom.
  • Small multiples Repeated panels make the effects of different settings easier to compare.
  • Sequential and diverging scales Each heatmap has its own legend; check the ranges before comparing colors.