Skip to content
KONIGI Patterns

Heatmap

People need to see how a large set of values is distributed over time.

Updated September 10, 2026

Problem

Averages can hide distinct groups and unusual values in a large set of events. People need to see how the distribution changes over time.

Solution

Group events into time intervals and value ranges. Color each cell by the number of events in that group, with a clear legend.

Choose value ranges that reveal the distribution. A logarithmic scale can help when values span a wide range, but label it clearly. Show how bucket boundaries and color limits are set.

Keep exact counts available through an accessible detail view. Use matching axes and color scales when people need to compare heatmaps.

Use when

There are too many events to show individually and the shape of their distribution over time matters.

Don’t use when

The volume is low. A heatmap over forty events is a sparse grid of near-identical cells, and a scatter plot would show the same thing with less machinery. Also avoid it when the audience reads the page from ten feet away—colour density needs a close look.

Trade-offs

Heatmaps take practice to read. Bucket choices can hide or exaggerate patterns, and exact values are less obvious than in a table. Automatic color scales can make different datasets look deceptively similar.

Checklist

  • Are the y buckets linear or log, and does the choice suit a skewed distribution?
  • If log, base 2 or base 10, and does the labelling make the base obvious?
  • Where do bucket boundaries fall relative to the values people care about?
  • Is the colour scale shared with any neighbouring heatmap, and if not, is that stated?
  • Is the scale clamped, and does one extreme cell dominate the ramp if it isn’t?
  • Does the colour ramp survive the viewer’s colour vision, and does it survive greyscale printing?
  • Can the viewer get from a dense region to the underlying events?
  • What does an empty cell look like, and can it be confused with a low-count cell?
  • Is a companion percentile or count available for people who need a number?
  • Does the panel say what one cell represents, in both axes?

Compare

Grafana treats it as a panel with a lot of knobs—bucket source, three y-scale families, two colour modes, clamps at both ends—which makes it the most configurable version and the easiest to leave on defaults that hide the data. Honeycomb builds its whole analysis loop on top of it: draw a box around the interesting region and BubbleUp compares the events inside against the baseline outside, so the heatmap is a selection surface rather than a picture. Datadog offers it as a distribution view alongside percentile lines, which lets a team keep the line they already trust and check it against the shape when it disagrees. Netdata mostly declines the pattern, because per-second resolution on ordinary charts already shows detail that other tools only recover by bucketing.

Histogram shows a distribution without time. Percentile summary reduces it to selected values. Time series shows a measure at each interval.

Sequential and diverging scales defines the color mapping. Small multiples supports comparisons between heatmaps.

Heatmap anatomy The same latency data bucketed two ways. On a linear y-axis everything crushes into a band at the bottom and most of the panel is empty. On logarithmic buckets the distribution separates into two bands—a fast path and a slow path—and the slow one is growing. Same events, two bucketings 0ms 240ms 480ms 720ms 960ms Linear buckets 10ms 38ms 143ms 542ms 2s Log buckets 1 2 1 THE Y-AXIS DECIDES EVERYTHING Latency distributions are heavily skewed, so linear buckets crush almost everything into one row and leave the panel empty above it. 2 TWO BANDS, NOT ONE A fast path and a slow path. Cache hit and cache miss. A percentile reports a position in the distribution and cannot show you that the distribution has two humps. Colour needs clamping too. Without it one catastrophic cell consumes the whole ramp and turns every other cell the same shade of nothing. And the second band here is getting wider from left to right, which is the finding.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A matrix where colour is the value. It only works if the ramp is monotonic in lightness and the legend carries real numbers, otherwise it is a mood board with axes.

Tokens
--card--muted-foreground--border--scale-seq-1--scale-seq-2--scale-seq-3--scale-seq-4--scale-seq-5
14:00 · 27ms–38ms · 5 events14:00 · 38ms–53ms · 65 events14:00 · 53ms–74ms · 5 events14:00 · 74ms–103ms · 1 events14:00 · 542ms–756ms · 1 events14:00 · 756ms–1s · 1 events14:00 · 1s–1s · 1 events14:01 · 27ms–38ms · 5 events14:01 · 38ms–53ms · 65 events14:01 · 53ms–74ms · 5 events14:01 · 74ms–103ms · 1 events14:01 · 542ms–756ms · 1 events14:01 · 756ms–1s · 1 events14:01 · 1s–1s · 1 events14:02 · 27ms–38ms · 5 events14:02 · 38ms–53ms · 65 events14:02 · 53ms–74ms · 5 events14:02 · 74ms–103ms · 1 events14:02 · 542ms–756ms · 1 events14:02 · 756ms–1s · 1 events14:02 · 1s–1s · 1 events14:03 · 27ms–38ms · 5 events14:03 · 38ms–53ms · 65 events14:03 · 53ms–74ms · 5 events14:03 · 74ms–103ms · 1 events14:03 · 542ms–756ms · 1 events14:03 · 756ms–1s · 5 events14:03 · 1s–1s · 1 events14:04 · 27ms–38ms · 5 events14:04 · 38ms–53ms · 65 events14:04 · 53ms–74ms · 5 events14:04 · 74ms–103ms · 1 events14:04 · 542ms–756ms · 1 events14:04 · 756ms–1s · 5 events14:04 · 1s–1s · 1 events14:05 · 27ms–38ms · 5 events14:05 · 38ms–53ms · 65 events14:05 · 53ms–74ms · 5 events14:05 · 74ms–103ms · 1 events14:05 · 542ms–756ms · 1 events14:05 · 756ms–1s · 5 events14:05 · 1s–1s · 1 events14:06 · 27ms–38ms · 5 events14:06 · 38ms–53ms · 65 events14:06 · 53ms–74ms · 5 events14:06 · 74ms–103ms · 1 events14:06 · 542ms–756ms · 1 events14:06 · 756ms–1s · 5 events14:06 · 1s–1s · 1 events14:07 · 27ms–38ms · 5 events14:07 · 38ms–53ms · 65 events14:07 · 53ms–74ms · 5 events14:07 · 74ms–103ms · 1 events14:07 · 542ms–756ms · 1 events14:07 · 756ms–1s · 5 events14:07 · 1s–1s · 1 events14:08 · 27ms–38ms · 5 events14:08 · 38ms–53ms · 65 events14:08 · 53ms–74ms · 5 events14:08 · 74ms–103ms · 1 events14:08 · 542ms–756ms · 1 events14:08 · 756ms–1s · 5 events14:08 · 1s–1s · 1 events14:08 · 1s–2s · 1 events14:09 · 27ms–38ms · 5 events14:09 · 38ms–53ms · 65 events14:09 · 53ms–74ms · 5 events14:09 · 74ms–103ms · 1 events14:09 · 542ms–756ms · 1 events14:09 · 756ms–1s · 18 events14:09 · 1s–1s · 1 events14:09 · 1s–2s · 1 events14:10 · 27ms–38ms · 5 events14:10 · 38ms–53ms · 65 events14:10 · 53ms–74ms · 5 events14:10 · 74ms–103ms · 1 events14:10 · 389ms–542ms · 1 events14:10 · 542ms–756ms · 1 events14:10 · 756ms–1s · 18 events14:10 · 1s–1s · 5 events14:10 · 1s–2s · 1 events14:11 · 27ms–38ms · 5 events14:11 · 38ms–53ms · 65 events14:11 · 53ms–74ms · 5 events14:11 · 74ms–103ms · 1 events14:11 · 389ms–542ms · 1 events14:11 · 542ms–756ms · 5 events14:11 · 756ms–1s · 18 events14:11 · 1s–1s · 5 events14:11 · 1s–2s · 1 events14:12 · 27ms–38ms · 5 events14:12 · 38ms–53ms · 65 events14:12 · 53ms–74ms · 5 events14:12 · 74ms–103ms · 1 events14:12 · 389ms–542ms · 1 events14:12 · 542ms–756ms · 5 events14:12 · 756ms–1s · 18 events14:12 · 1s–1s · 5 events14:12 · 1s–2s · 1 events14:13 · 27ms–38ms · 5 events14:13 · 38ms–53ms · 65 events14:13 · 53ms–74ms · 5 events14:13 · 74ms–103ms · 1 events14:13 · 389ms–542ms · 1 events14:13 · 542ms–756ms · 5 events14:13 · 756ms–1s · 18 events14:13 · 1s–1s · 5 events14:13 · 1s–2s · 1 events14:14 · 27ms–38ms · 5 events14:14 · 38ms–53ms · 65 events14:14 · 53ms–74ms · 5 events14:14 · 74ms–103ms · 1 events14:14 · 389ms–542ms · 1 events14:14 · 542ms–756ms · 5 events14:14 · 756ms–1s · 18 events14:14 · 1s–1s · 5 events14:14 · 1s–2s · 1 events14:15 · 27ms–38ms · 5 events14:15 · 38ms–53ms · 65 events14:15 · 53ms–74ms · 5 events14:15 · 74ms–103ms · 1 events14:15 · 389ms–542ms · 1 events14:15 · 542ms–756ms · 5 events14:15 · 756ms–1s · 18 events14:15 · 1s–1s · 5 events14:15 · 1s–2s · 1 events14:16 · 27ms–38ms · 5 events14:16 · 38ms–53ms · 65 events14:16 · 53ms–74ms · 5 events14:16 · 74ms–103ms · 1 events14:16 · 389ms–542ms · 1 events14:16 · 542ms–756ms · 5 events14:16 · 756ms–1s · 65 events14:16 · 1s–1s · 5 events14:16 · 1s–2s · 1 events14:17 · 27ms–38ms · 5 events14:17 · 38ms–53ms · 65 events14:17 · 53ms–74ms · 5 events14:17 · 74ms–103ms · 1 events14:17 · 389ms–542ms · 1 events14:17 · 542ms–756ms · 5 events14:17 · 756ms–1s · 65 events14:17 · 1s–1s · 5 events14:17 · 1s–2s · 1 events14:18 · 27ms–38ms · 5 events14:18 · 38ms–53ms · 65 events14:18 · 53ms–74ms · 5 events14:18 · 74ms–103ms · 1 events14:18 · 389ms–542ms · 1 events14:18 · 542ms–756ms · 5 events14:18 · 756ms–1s · 65 events14:18 · 1s–1s · 5 events14:18 · 1s–2s · 1 events14:19 · 27ms–38ms · 5 events14:19 · 38ms–53ms · 65 events14:19 · 53ms–74ms · 5 events14:19 · 74ms–103ms · 1 events14:19 · 389ms–542ms · 1 events14:19 · 542ms–756ms · 5 events14:19 · 756ms–1s · 65 events14:19 · 1s–1s · 5 events14:19 · 1s–2s · 1 events14:20 · 27ms–38ms · 5 events14:20 · 38ms–53ms · 65 events14:20 · 53ms–74ms · 5 events14:20 · 74ms–103ms · 1 events14:20 · 389ms–542ms · 1 events14:20 · 542ms–756ms · 5 events14:20 · 756ms–1s · 65 events14:20 · 1s–1s · 5 events14:20 · 1s–2s · 1 events14:21 · 27ms–38ms · 5 events14:21 · 38ms–53ms · 65 events14:21 · 53ms–74ms · 5 events14:21 · 74ms–103ms · 1 events14:21 · 389ms–542ms · 1 events14:21 · 542ms–756ms · 5 events14:21 · 756ms–1s · 65 events14:21 · 1s–1s · 18 events14:21 · 1s–2s · 1 events14:22 · 27ms–38ms · 5 events14:22 · 38ms–53ms · 65 events14:22 · 53ms–74ms · 5 events14:22 · 74ms–103ms · 1 events14:22 · 389ms–542ms · 1 events14:22 · 542ms–756ms · 18 events14:22 · 756ms–1s · 300 events14:22 · 1s–1s · 18 events14:22 · 1s–2s · 1 events14:23 · 27ms–38ms · 5 events14:23 · 38ms–53ms · 65 events14:23 · 53ms–74ms · 5 events14:23 · 74ms–103ms · 1 events14:23 · 389ms–542ms · 1 events14:23 · 542ms–756ms · 18 events14:23 · 756ms–1s · 300 events14:23 · 1s–1s · 18 events14:23 · 1s–2s · 1 events14:24 · 27ms–38ms · 5 events14:24 · 38ms–53ms · 65 events14:24 · 53ms–74ms · 5 events14:24 · 74ms–103ms · 1 events14:24 · 389ms–542ms · 1 events14:24 · 542ms–756ms · 18 events14:24 · 756ms–1s · 300 events14:24 · 1s–1s · 18 events14:24 · 1s–2s · 1 events14:25 · 27ms–38ms · 5 events14:25 · 38ms–53ms · 65 events14:25 · 53ms–74ms · 5 events14:25 · 74ms–103ms · 1 events14:25 · 389ms–542ms · 1 events14:25 · 542ms–756ms · 18 events14:25 · 756ms–1s · 300 events14:25 · 1s–1s · 18 events14:25 · 1s–2s · 1 events10ms38ms143ms542ms2s8s14:0014:1314:25
01393096+one cell is 1 min × one bucket (×1.39 per row) · ramp clamped at p99 = 300

Heatmap.tsxLog y buckets spread between the data's extent, a ramp clamped at a percentile, and a legend with the count each step starts at.

/**
 * Bucket on both axes and encode the count as colour. Time across the bottom,
 * the measured value up the side, and the y buckets logarithmic, because a
 * latency distribution is skewed and a linear axis crushes it into one row.
 *
 * Drawn by hand: recharts has no heatmap, and the colour step per cell is the
 * whole point.
 */
const STEP = ["fill-scale-seq-1", "fill-scale-seq-2", "fill-scale-seq-3", "fill-scale-seq-4", "fill-scale-seq-5"];
const SWATCH = ["bg-scale-seq-1", "bg-scale-seq-2", "bg-scale-seq-3", "bg-scale-seq-4", "bg-scale-seq-5"];

type Props = {
  /** counts[column][row], row 0 the lowest bucket. Pre-bucketed upstream. */
  counts: number[][];
  /** The y extent in ms. Rows are spread logarithmically between the two. */
  y: { min: number; max: number };
  x: { from: Date; stepMs: number };
  /** The ramp ends at this percentile of the non-empty cells, so one
   *  catastrophic cell cannot take the whole ramp for itself. */
  clampAt?: number;
  cell?: { w: number; h: number };
};

const ms = (v: number) => (v < 1000 ? `${Math.round(v)}ms` : `${Number((v / 1000).toPrecision(1))}s`);
const hhmm = (d: Date) => d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: "UTC" });

export function Heatmap({ counts, y, x, clampAt = 0.99, cell = { w: 10, h: 9 } }: Props) {
  const cols = counts.length;
  const rows = counts[0].length;
  const ratio = Math.pow(y.max / y.min, 1 / rows);
  const edge = (row: number) => y.min * Math.pow(ratio, row);

  // Clamp first, then five equal steps in log(count). Cells above the clamp
  // share the top colour rather than pushing everything else into the bottom.
  const nonEmpty = counts.flat().filter((c) => c > 0).sort((a, b) => a - b);
  const clamp = nonEmpty[Math.floor(clampAt * (nonEmpty.length - 1))] ?? 1;
  const top = Math.log1p(clamp);
  const step = (c: number) => Math.min(4, Math.floor((Math.log1p(c) / top) * 5));
  const lower = (k: number) => Math.ceil(Math.expm1((k / 5) * top));

  const GUTTER = 44, FOOT = 18;
  const W = GUTTER + cols * cell.w, H = rows * cell.h + FOOT;
  const time = (col: number) => hhmm(new Date(x.from.getTime() + col * x.stepMs));

  return (
    <figure className="m-0">
      <div className="overflow-x-auto">
        <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="block text-[10px]" role="img" aria-label="Latency by minute">
          {counts.map((column, c) =>
            column.map((n, r) =>
              n > 0 && (
                <rect
                  key={`${c}-${r}`}
                  x={GUTTER + c * cell.w} y={(rows - 1 - r) * cell.h}
                  width={cell.w} height={cell.h}
                  className={STEP[step(n)]}
                >
                  <title>{`${time(c)} · ${ms(edge(r))}–${ms(edge(r + 1))} · ${n} events`}</title>
                </rect>
              ),
            ),
          )}
          <line x1={GUTTER} y1={rows * cell.h} x2={W} y2={rows * cell.h} className="stroke-border" />
          {/* A tick every four rows, labelled with the bucket's lower edge, so
              the base of the log is on the page rather than in the config. */}
          {Array.from({ length: Math.floor(rows / 4) + 1 }, (_, i) => i * 4).map((r) => (
            <text key={r} x={GUTTER - 6} y={(rows - r) * cell.h + 3} textAnchor="end" className="fill-muted-foreground tabular-nums">
              {ms(edge(r))}
            </text>
          ))}
          {[0, Math.floor(cols / 2), cols - 1].map((c) => (
            <text key={c} x={GUTTER + c * cell.w + cell.w / 2} y={H - 4} textAnchor="middle" className="fill-muted-foreground tabular-nums">
              {time(c)}
            </text>
          ))}
        </svg>
      </div>

      {/* The legend carries real numbers. Without them the ramp is a mood. */}
      <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-muted-foreground">
        <span className="flex items-center gap-1">
          <span className="size-2.5 rounded-[1px] border border-border" aria-hidden="true" /> 0
        </span>
        {SWATCH.map((cls, k) => (
          <span key={cls} className="flex items-center gap-1">
            <span className={`size-2.5 rounded-[1px] ${cls}`} aria-hidden="true" />
            {k === 4 ? `${lower(k)}+` : lower(k) || 1}
          </span>
        ))}
        <span className="ml-auto tabular-nums">
          one cell is {Math.round(x.stepMs / 60_000)} min × one bucket (×{ratio.toFixed(2)} per row) · ramp clamped at p{Math.round(clampAt * 100)} = {clamp}
        </span>
      </figcaption>
    </figure>
  );
}

demo.tsxHow it is called: twenty-six minutes of latency counts in twenty buckets, two bands, the slow one widening.

import { Heatmap } from "./Heatmap";

/**
 * Twenty-six minutes of request latency in twenty log buckets, from the
 * fastest request in the window (10 ms) to the slowest (7.76 s). Two bands:
 * a fast path around 40 ms and a slow one that starts near 300 ms and gets
 * wider from left to right. Each column is one minute, lowest bucket first,
 * with the count of events in each cell.
 */
const COUNT: Record<string, number> = { "0": 0, "1": 1, "2": 5, "3": 18, "4": 65, "5": 300 };
const MINUTES = [
  "00024210000011100000", "00024210000011100000", "00024210000011100000", "00024210000012100000",
  "00024210000012100000", "00024210000012100000", "00024210000012100000", "00024210000012100000",
  "00024210000012110000", "00024210000013110000", "00024210000113210000", "00024210000123210000",
  "00024210000123210000", "00024210000123210000", "00024210000123210000", "00024210000123210000",
  "00024210000124210000", "00024210000124210000", "00024210000124210000", "00024210000124210000",
  "00024210000124210000", "00024210000124310000", "00024210000135310000", "00024210000135310000",
  "00024210000135310000", "00024210000135310000",
].map((column) => [...column].map((d) => COUNT[d]));

export default function Demo() {
  return (
    <div className="rounded-lg border bg-card p-3">
      <Heatmap
        counts={MINUTES}
        y={{ min: 10, max: 7764 }}
        x={{ from: new Date("2026-09-15T14:00:00Z"), stepMs: 60_000 }}
      />
    </div>
  );
}
What it renders. Identical markup in all three 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.

Honeycomb

Query-driven views use heatmaps and selected regions to investigate differences between requests.

Query / HEATMAP(duration_ms) September 10, 2026 Honeycomb sandbox, public dataset (signed out; no version string exposed) medium · light · desktop-web
This sandbox heatmap uses a linear vertical scale. Most requests sit near the bottom, while a separate group of slower requests extends upward later in the period. The view exposes variation that a single summary value would hide, though a different scale could give the lower band more room. The footer reports query duration and rows examined. A cookie banner and onboarding dialog were removed for the capture; the dashboard itself was left intact.
Show 1 more example Hide the rest

Grafana

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

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.