Skip to content
KONIGI

Dashboards / Visual representation / Calendar heatmap

3 of 22

Calendar heatmap

Daily activity over a year, read for streaks and gaps.

Updated September 10, 2026

Problem

A year of daily numbers is 365 points. As a line it is a hairy band where weekends and weekdays average together. The viewer wants to see that Tuesdays are busy, that August was quiet, and that one week in March went strange.

Solution

One cell per day, laid out as a calendar. Weeks as columns, days of the week as rows, colour for magnitude. A year fits in a strip a few centimetres tall and the eye picks out both the weekly rhythm and the seasonal one without being told to look.

The layout is the encoding. Because every row is the same weekday, a weekend effect appears as two consistently pale rows across the whole chart, which no line chart shows without being asked. Because columns are consecutive weeks, a quiet fortnight is a pale vertical gap. Both readings come free from the arrangement.

GitHub’s contribution graph is what made the pattern universally legible, and it also shows the pattern’s main weakness. Its cells are bucketed into a handful of intensity levels rather than mapped continuously, so a day with 4 commits and a day with 40 can look similar. That is a deliberate trade: coarse buckets make the rhythm readable and make individual days approximate. It works for that use and fails for any question about magnitude.

The variant worth knowing is the punch card: hour of day against day of week, cells sized or coloured by volume. Same idea, different cycle, and usually a better answer for anything with a daily shape rather than a weekly one.

Use when

The data has a genuine calendar rhythm, the period is long enough that a line chart becomes dense, and the question is about pattern rather than value. Deploys, incidents, sales, activity, on-call load.

Don’t use when

Exact values matter, or the period is short. A month of data in this layout is four columns and a line chart says more. Also poor for anything without a weekly cycle, where the row structure encodes nothing and only adds work.

Trade-offs

Colour carries all the magnitude, so the scale choice determines what is visible and a poorly chosen ramp can flatten a real seasonal change into uniformity. Outliers dominate: one enormous day pushes everything else to the bottom of the scale unless the range is clamped. The grid is small, so it fails at wallboard distance and on phones. And the pattern is strongly associated with GitHub, which means viewers arrive expecting “activity” semantics and read a gap as someone not working rather than as a metric being low.

Checklist

  • Does this data actually have a weekly or seasonal cycle worth showing?
  • Is the colour scale continuous or bucketed, and does that suit the question?
  • Is the range clamped so one outlier does not flatten the rest?
  • Is the scale legend present, with real values on it?
  • Does a zero-value day look different from a day with no data?
  • Are weekends or non-working days distinguished where that matters?
  • Which day does the week start on, and does it match the audience’s convention?
  • Is there a hover giving the date and the exact value?
  • Does the grid stay legible at the size it renders at?
  • Would viewers import GitHub’s “activity” reading and misinterpret a pale cell?

Compare

GitHub’s contribution graph is the reference implementation and the reason the pattern needs no explaining, using coarse intensity buckets that make the rhythm legible while making individual days deliberately approximate. Grafana has no calendar-layout panel in core, so teams reach for community plugins or reshape data into the standard heatmap, losing the calendar semantics that make the layout worth having. Datadog applies the punch-card variant to incident and alert timing, which is the strongest operational use of the idea because on-call load genuinely has an hour-of-day shape. Observable and D3 are where most bespoke versions get built, which is why the pattern is common in one-off analyses and rare inside monitoring products.

Heatmap is the general form, with continuous axes rather than calendar ones. Cohort grid uses the same cell-grid reading against cohort age. Sequential and diverging scales decides whether the colour ramp reveals or flattens. Status history is the strip version when the value is a state rather than a magnitude. Time series is the alternative when exact values matter more than rhythm.

Calendar heatmap anatomy A year of daily activity, one cell per day, weeks as columns and weekdays as rows. The two weekend rows are consistently pale across the whole chart, and a fortnight off in July is a pale vertical gap. Both readings come from the layout rather than from anything anyone labelled. A year in a strip Jan Mar May Jul Sep Nov Sun Tue Thu Sat 1 2 Less More 3 1 EVERY ROW IS ONE WEEKDAY So a weekend effect is two consistently pale rows running the whole width, which no line chart shows without being asked. 2 EVERY COLUMN IS ONE WEEK So a quiet fortnight is a pale vertical gap. Both readings come from the arrangement, not from anything anyone labelled. 3 COARSE BUCKETS, ON PURPOSE A handful of intensity steps rather than a continuous ramp. It makes the rhythm readable and makes individual days approximate — a day with four and a day with forty can land on the same shade. Right for the rhythm, wrong for magnitude. The variant worth knowing is the punch card: hour of day against day of week, which is the better answer for anything with a daily shape rather than a weekly one.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A heatmap whose x-axis is the calendar, so weekly rhythm and holidays become visible. The empty cell has to differ from the zero cell or a quiet week reads as missing data.

Tokens
--card--muted--muted-foreground--border--scale-seq-1--scale-seq-2--scale-seq-3--scale-seq-4--scale-seq-5
JanFebMarAprMayJunJulAugSepOctNovDecSunTueThuSat2023-01-01: 0 deploys2023-01-02: 0 deploys2023-01-03: 2 deploys2023-01-04: 0 deploys2023-01-05: 0 deploys2023-01-06: 2 deploys2023-01-07: 0 deploys2023-01-08: 0 deploys2023-01-09: 1 deploys2023-01-10: 3 deploys2023-01-11: 1 deploys2023-01-12: 1 deploys2023-01-13: 3 deploys2023-01-14: 0 deploys2023-01-15: 0 deploys2023-01-16: 1 deploys2023-01-17: 1 deploys2023-01-18: 3 deploys2023-01-19: 3 deploys2023-01-20: 3 deploys2023-01-21: 0 deploys2023-01-22: 0 deploys2023-01-23: 2 deploys2023-01-24: 2 deploys2023-01-25: 1 deploys2023-01-26: 3 deploys2023-01-27: 1 deploys2023-01-28: 0 deploys2023-01-29: 0 deploys2023-01-30: 1 deploys2023-01-31: 3 deploys2023-02-01: 3 deploys2023-02-02: 8 deploys2023-02-03: 3 deploys2023-02-04: 0 deploys2023-02-05: 0 deploys2023-02-06: 4 deploys2023-02-07: 2 deploys2023-02-08: 4 deploys2023-02-09: 7 deploys2023-02-10: 2 deploys2023-02-11: 0 deploys2023-02-12: 0 deploys2023-02-13: 1 deploys2023-02-14: 3 deploys2023-02-15: 2 deploys2023-02-16: 5 deploys2023-02-17: 2 deploys2023-02-18: 0 deploys2023-02-19: 2 deploys2023-02-20: 1 deploys2023-02-21: 8 deploys2023-02-22: 7 deploys2023-02-23: 2 deploys2023-02-24: 7 deploys2023-02-25: 0 deploys2023-02-26: 0 deploys2023-02-27: 2 deploys2023-02-28: 5 deploys2023-03-01: 1 deploys2023-03-02: 6 deploys2023-03-03: 6 deploys2023-03-04: 0 deploys2023-03-05: 1 deploys2023-03-06: 12 deploys2023-03-07: 1 deploys2023-03-08: 3 deploys2023-03-09: 5 deploys2023-03-10: 4 deploys2023-03-11: 3 deploys2023-03-12: 0 deploys2023-03-13: 7 deploys2023-03-14: 8 deploys2023-03-15: 5 deploys2023-03-16: 2 deploys2023-03-17: 11 deploys2023-03-18: 2 deploys2023-03-19: 3 deploys2023-03-20: 10 deploys2023-03-21: 10 deploys2023-03-22: 2 deploys2023-03-23: 4 deploys2023-03-24: 10 deploys2023-03-25: 3 deploys2023-03-26: 3 deploys2023-03-27: 12 deploys2023-03-28: 11 deploys2023-03-29: 12 deploys2023-03-30: 5 deploys2023-03-31: 5 deploys2023-04-01: 3 deploys2023-04-02: 1 deploys2023-04-03: 6 deploys2023-04-04: 16 deploys2023-04-05: 8 deploys2023-04-06: 3 deploys2023-04-07: 6 deploys2023-04-08: 1 deploys2023-04-09: 3 deploys2023-04-10: 11 deploys2023-04-11: 5 deploys2023-04-12: 6 deploys2023-04-13: 27 deploys2023-04-14: 11 deploys2023-04-15: 1 deploys2023-04-16: 2 deploys2023-04-17: 4 deploys2023-04-18: 8 deploys2023-04-19: 10 deploys2023-04-20: 6 deploys2023-04-21: 6 deploys2023-04-22: 3 deploys2023-04-23: 3 deploys2023-04-24: 30 deploys2023-04-25: 5 deploys2023-04-26: 10 deploys2023-04-27: 23 deploys2023-04-28: 11 deploys2023-04-29: 3 deploys2023-04-30: 2 deploys2023-05-01: 8 deploys2023-05-02: 14 deploys2023-05-03: 6 deploys2023-05-04: 15 deploys2023-05-05: 11 deploys2023-05-06: 2 deploys2023-05-07: 3 deploys2023-05-08: 12 deploys2023-05-09: 5 deploys2023-05-10: 14 deploys2023-05-11: 15 deploys2023-05-12: 8 deploys2023-05-13: 1 deploys2023-05-14: 1 deploys2023-05-15: 5 deploys2023-05-16: 4 deploys2023-05-17: 18 deploys2023-05-18: 25 deploys2023-05-19: 28 deploys2023-05-20: 1 deploys2023-05-21: 2 deploys2023-05-22: 20 deploys2023-05-23: 18 deploys2023-05-24: 25 deploys2023-05-25: 13 deploys2023-05-26: 8 deploys2023-05-27: 3 deploys2023-05-28: 1 deploys2023-05-29: 26 deploys2023-05-30: 17 deploys2023-05-31: 14 deploys2023-06-01: 21 deploys2023-06-02: 20 deploys2023-06-03: 3 deploys2023-06-04: 1 deploys2023-06-05: 28 deploys2023-06-06: 21 deploys2023-06-07: 20 deploys2023-06-08: 13 deploys2023-06-09: 23 deploys2023-06-10: 2 deploys2023-06-11: 3 deploys2023-06-12: 22 deploys2023-06-13: 30 deploys2023-06-14: 17 deploys2023-06-15: 6 deploys2023-06-16: 25 deploys2023-06-17: 1 deploys2023-06-18: 2 deploys2023-06-19: 27 deploys2023-06-20: 6 deploys2023-06-21: 4 deploys2023-06-22: 29 deploys2023-06-23: 23 deploys2023-06-24: 2 deploys2023-06-25: 3 deploys2023-06-26: 14 deploys2023-06-27: 4 deploys2023-06-28: 29 deploys2023-06-29: 5 deploys2023-06-30: 17 deploys2023-07-01: 1 deploys2023-07-02: 1 deploys2023-07-03: 27 deploys2023-07-04: 27 deploys2023-07-05: 24 deploys2023-07-06: 16 deploys2023-07-07: 11 deploys2023-07-08: 3 deploys2023-07-09: 2 deploys2023-07-10: 18 deploys2023-07-11: 19 deploys2023-07-12: 10 deploys2023-07-13: 13 deploys2023-07-14: 6 deploys2023-07-15: 1 deploys2023-07-16: 0 deploys2023-07-17: 0 deploys2023-07-18: 0 deploys2023-07-19: 0 deploys2023-07-20: 0 deploys2023-07-21: 0 deploys2023-07-22: 0 deploys2023-07-23: 0 deploys2023-07-24: 0 deploys2023-07-25: 0 deploys2023-07-26: 0 deploys2023-07-27: 0 deploys2023-07-28: 0 deploys2023-07-29: 0 deploys2023-07-30: 1 deploys2023-07-31: 12 deploys2023-08-01: 4 deploys2023-08-02: 15 deploys2023-08-03: 20 deploys2023-08-04: 4 deploys2023-08-05: 1 deploys2023-08-06: 1 deploys2023-08-07: 23 deploys2023-08-08: 12 deploys2023-08-09: 15 deploys2023-08-10: 26 deploys2023-08-11: 24 deploys2023-08-12: 1 deploys2023-08-13: 1 deploys2023-08-14: 7 deploys2023-08-15: 7 deploys2023-08-16: 23 deploys2023-08-17: 9 deploys2023-08-18: 14 deploys2023-08-19: 3 deploys2023-08-20: 1 deploys2023-08-21: 4 deploys2023-08-22: 5 deploys2023-08-23: 10 deploys2023-08-24: 5 deploys2023-08-25: 13 deploys2023-08-26: 2 deploys2023-08-27: 3 deploys2023-08-28: 2 deploys2023-08-29: 8 deploys2023-08-30: 12 deploys2023-08-31: 4 deploys2023-09-01: 13 deploys2023-09-02: 2 deploys2023-09-03: 0 deploys2023-09-04: 7 deploys2023-09-05: 15 deploys2023-09-06: 7 deploys2023-09-07: 13 deploys2023-09-08: 15 deploys2023-09-09: 1 deploys2023-09-10: 3 deploys2023-09-11: 6 deploys2023-09-12: 8 deploys2023-09-13: 15 deploys2023-09-14: 8 deploys2023-09-15: 15 deploys2023-09-16: 2 deploys2023-09-17: 1 deploys2023-09-18: 7 deploys2023-09-19: 4 deploys2023-09-20: 7 deploys2023-09-21: 1 deploys2023-09-22: 8 deploys2023-09-23: 0 deploys2023-09-24: 3 deploys2023-09-25: 3 deploys2023-09-26: 1 deploys2023-09-27: 2 deploys2023-09-28: 4 deploys2023-09-29: 2 deploys2023-09-30: 1 deploys2023-10-01: 0 deploys2023-10-02: 5 deploys2023-10-03: 2 deploys2023-10-04: 7 deploys2023-10-05: 3 deploys2023-10-06: 3 deploys2023-10-07: 2 deploys2023-10-08: 0 deploys2023-10-09: 6 deploys2023-10-10: 2 deploys2023-10-11: 1 deploys2023-10-12: 2 deploys2023-10-13: 2 deploys2023-10-14: 1 deploys2023-10-15: 0 deploys2023-10-16: 4 deploys2023-10-17: 4 deploys2023-10-18: 5 deploys2023-10-19: 1 deploys2023-10-20: 2 deploys2023-10-21: 0 deploys2023-10-22: 0 deploys2023-10-23: 6 deploys2023-10-24: 3 deploys2023-10-25: 8 deploys2023-10-26: 3 deploys2023-10-27: 2 deploys2023-10-28: 0 deploys2023-10-29: 0 deploys2023-10-30: 1 deploys2023-10-31: 2 deploys2023-11-01: 3 deploys2023-11-02: 2 deploys2023-11-03: 1 deploys2023-11-04: 0 deploys2023-11-05: 0 deploys2023-11-06: 3 deploys2023-11-07: 3 deploys2023-11-08: 3 deploys2023-11-09: 2 deploys2023-11-10: 1 deploys2023-11-11: 0 deploys2023-11-12: 0 deploys2023-11-13: 3 deploys2023-11-14: 2 deploys2023-11-15: 2 deploys2023-11-16: 1 deploys2023-11-17: 1 deploys2023-11-18: 0 deploys2023-11-19: 0 deploys2023-11-20: 3 deploys2023-11-21: 1 deploys2023-11-22: 2 deploys2023-11-23: 2 deploys2023-11-24: 3 deploys2023-11-25: 0 deploys2023-11-26: 0 deploys2023-11-27: 0 deploys2023-11-28: 1 deploys2023-11-29: 3 deploys2023-11-30: 1 deploys2023-12-01: 2 deploys2023-12-02: 0 deploys2023-12-03: 0 deploys2023-12-04: 0 deploys2023-12-05: 0 deploys2023-12-06: 3 deploys2023-12-07: 2 deploys2023-12-08: 3 deploys2023-12-09: 0 deploys2023-12-10: 0 deploys2023-12-11: 0 deploys2023-12-12: 2 deploys2023-12-13: 0 deploys2023-12-14: 2 deploys2023-12-15: 1 deploys2023-12-16: 0 deploys2023-12-17: 0 deploys2023-12-18: 0 deploys2023-12-19: 3 deploys2023-12-20: 0 deploys2023-12-21: 0 deploys2023-12-22: 0 deploys2023-12-23: 0 deploys2023-12-24: 0 deploys2023-12-25: 0 deploys2023-12-26: 1 deploys2023-12-27: 0 deploys2023-12-28: 2 deploys2023-12-29: 1 deploys2023-12-30: 0 deploys2023-12-31: no data
LessMoreNo data

CalendarHeatmap.tsxLays days out by date, buckets values against thresholds the caller names, and draws a missing day as an outline rather than as zero.

/**
 * The layout is the encoding. Every row is one weekday and every column is
 * one week, so a weekend effect and a quiet fortnight are both visible from
 * the arrangement alone. Colour is bucketed on purpose: a handful of steps
 * makes the rhythm readable and makes individual days approximate, and the
 * exact value lives in the hover.
 *
 * `null` is a day with no data. It is drawn as an outline, because a day that
 * was not measured is a different fact from a day whose value was zero.
 */
const CELL = 10, GAP = 2, STEP = CELL + GAP;
const DAY = 86_400_000;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
/** Full class names, because Tailwind only ships the ones it can see. */
const FILL = ["fill-muted", "fill-scale-seq-1", "fill-scale-seq-2", "fill-scale-seq-3", "fill-scale-seq-4", "fill-scale-seq-5"];

export function CalendarHeatmap({ start, values, thresholds, unit, weekStart = 0, onSelect }: {
  /** ISO date of values[0]. */
  start: string;
  /** One entry per consecutive day. `null` means no sample arrived. */
  values: (number | null)[];
  /** Lower bound of each coloured step, ascending: five of them for a five-step ramp.
   *  Required, so the legend can show real values rather than "less" and "more" alone. */
  thresholds: number[];
  unit: string;
  weekStart?: 0 | 1;
  onSelect?: (date: string, value: number | null) => void;
}) {
  const t0 = Date.parse(start);
  const row = (t: number) => (new Date(t).getUTCDay() - weekStart + 7) % 7;
  const gridStart = t0 - row(t0) * DAY;
  const days = values.map((value, i) => {
    const t = t0 + i * DAY;
    return { t, value, col: Math.floor((t - gridStart) / (7 * DAY)), row: row(t) };
  });
  const cols = days[days.length - 1].col + 1;
  const bucket = (v: number) => thresholds.filter((th) => v >= th).length;
  const iso = (t: number) => new Date(t).toISOString().slice(0, 10);
  const monthStarts = days.filter((d) => new Date(d.t).getUTCDate() === 1 || d === days[0]);
  const left = 30, top = 16;
  const width = left + cols * STEP, height = top + 7 * STEP;

  return (
    <div>
      <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${unit} per day from ${start}`} className="block max-w-full">
        {monthStarts.map((d) => (
          <text key={d.t} x={left + d.col * STEP} y={10} className="fill-muted-foreground text-[10px]">{MONTHS[new Date(d.t).getUTCMonth()]}</text>
        ))}
        {[0, 2, 4, 6].map((r) => (
          <text key={r} x={left - 6} y={top + r * STEP + CELL - 1} textAnchor="end" className="fill-muted-foreground text-[10px]">{DAYS[(r + weekStart) % 7]}</text>
        ))}
        {days.map((d) => (
          <rect
            key={d.t}
            x={left + d.col * STEP}
            y={top + d.row * STEP}
            width={CELL}
            height={CELL}
            rx={1.5}
            className={d.value === null ? "fill-none stroke-border" : FILL[bucket(d.value)]}
            onClick={onSelect && (() => onSelect(iso(d.t), d.value))}
          >
            <title>{`${iso(d.t)}: ${d.value === null ? "no data" : `${d.value} ${unit}`}`}</title>
          </rect>
        ))}
      </svg>

      <div className="mt-3 flex items-center gap-1.5 text-[10px] text-muted-foreground" style={{ marginLeft: left }}>
        <span>Less</span>
        {FILL.map((cls, i) => (
          <svg key={cls} width={CELL} height={CELL} aria-hidden="true">
            <rect width={CELL} height={CELL} rx={1.5} className={cls}>
              <title>{i === 0 ? `0 ${unit}` : i === FILL.length - 1 ? `${thresholds[i - 1]}+ ${unit}` : `${thresholds[i - 1]}–${thresholds[i] - 1} ${unit}`}</title>
            </rect>
          </svg>
        ))}
        <span>More</span>
        <svg width={CELL} height={CELL} aria-hidden="true" className="ml-3"><rect x={0.5} y={0.5} width={CELL - 1} height={CELL - 1} rx={1.5} className="fill-none stroke-border" /></svg>
        <span>No data</span>
      </div>
    </div>
  );
}

deploys.tsThe year's counts, one row per week. The quiet fortnight and the pale weekend rows are in the data, not the drawing.

/**
 * Deploys per day for 2023, one row per week starting on Sunday, 1 January.
 * The two quiet columns in July are the fortnight the team was off. The
 * weekend rows are quiet all year, and nothing has to be labelled for the
 * reader to see either.
 */
export const START = "2023-01-01";

export const WEEKS: number[][] = [
  [ 0,  0,  2,  0,  0,  2,  0],
  [ 0,  1,  3,  1,  1,  3,  0],
  [ 0,  1,  1,  3,  3,  3,  0],
  [ 0,  2,  2,  1,  3,  1,  0],
  [ 0,  1,  3,  3,  8,  3,  0],
  [ 0,  4,  2,  4,  7,  2,  0],
  [ 0,  1,  3,  2,  5,  2,  0],
  [ 2,  1,  8,  7,  2,  7,  0],
  [ 0,  2,  5,  1,  6,  6,  0],
  [ 1, 12,  1,  3,  5,  4,  3],
  [ 0,  7,  8,  5,  2, 11,  2],
  [ 3, 10, 10,  2,  4, 10,  3],
  [ 3, 12, 11, 12,  5,  5,  3],
  [ 1,  6, 16,  8,  3,  6,  1],
  [ 3, 11,  5,  6, 27, 11,  1],
  [ 2,  4,  8, 10,  6,  6,  3],
  [ 3, 30,  5, 10, 23, 11,  3],
  [ 2,  8, 14,  6, 15, 11,  2],
  [ 3, 12,  5, 14, 15,  8,  1],
  [ 1,  5,  4, 18, 25, 28,  1],
  [ 2, 20, 18, 25, 13,  8,  3],
  [ 1, 26, 17, 14, 21, 20,  3],
  [ 1, 28, 21, 20, 13, 23,  2],
  [ 3, 22, 30, 17,  6, 25,  1],
  [ 2, 27,  6,  4, 29, 23,  2],
  [ 3, 14,  4, 29,  5, 17,  1],
  [ 1, 27, 27, 24, 16, 11,  3],
  [ 2, 18, 19, 10, 13,  6,  1],
  [ 0,  0,  0,  0,  0,  0,  0],
  [ 0,  0,  0,  0,  0,  0,  0],
  [ 1, 12,  4, 15, 20,  4,  1],
  [ 1, 23, 12, 15, 26, 24,  1],
  [ 1,  7,  7, 23,  9, 14,  3],
  [ 1,  4,  5, 10,  5, 13,  2],
  [ 3,  2,  8, 12,  4, 13,  2],
  [ 0,  7, 15,  7, 13, 15,  1],
  [ 3,  6,  8, 15,  8, 15,  2],
  [ 1,  7,  4,  7,  1,  8,  0],
  [ 3,  3,  1,  2,  4,  2,  1],
  [ 0,  5,  2,  7,  3,  3,  2],
  [ 0,  6,  2,  1,  2,  2,  1],
  [ 0,  4,  4,  5,  1,  2,  0],
  [ 0,  6,  3,  8,  3,  2,  0],
  [ 0,  1,  2,  3,  2,  1,  0],
  [ 0,  3,  3,  3,  2,  1,  0],
  [ 0,  3,  2,  2,  1,  1,  0],
  [ 0,  3,  1,  2,  2,  3,  0],
  [ 0,  0,  1,  3,  1,  2,  0],
  [ 0,  0,  0,  3,  2,  3,  0],
  [ 0,  0,  2,  0,  2,  1,  0],
  [ 0,  0,  3,  0,  0,  0,  0],
  [ 0,  0,  1,  0,  2,  1,  0],
];

demo.tsxHow it is called: a year of deploys with five real thresholds on the legend and one unsampled day at the end.

import { CalendarHeatmap } from "./CalendarHeatmap";
import { START, WEEKS } from "./deploys";

/**
 * A year of deploys. The data stops on 30 December, so the last day of the
 * year has no sample and draws as an outline rather than as a quiet day.
 */
export default function Demo() {
  return (
    <div className="w-fit rounded-lg border bg-card p-4">
      <CalendarHeatmap
        start={START}
        values={[...WEEKS.flat(), null]}
        thresholds={[1, 4, 9, 16, 31]}
        unit="deploys"
        onSelect={(date, value) => console.log(date, value)}
      />
    </div>
  );
}
What it renders. Identical markup in all three 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.

GitHub

The contribution graph is the calendar heatmap every other product copied, and its five-step bucketing is the deliberate trade that made it legible.

Profile / contribution graph September 11, 2026 github.com, signed out (public profile) medium · light · desktop-web
The calendar heatmap everyone else copied, and a good demonstration of what it trades away. 3,703 contributions across 365 days is about ten a day, and the scale has five steps, so almost every cell here lands in the middle two and the year reads as one flat field of mid-green. A day with five commits and a day with twenty-five are the same colour. That is the deliberate choice—coarse buckets make the rhythm readable and make any individual day approximate—but on a profile this active there is no rhythm left to read either, because the bucketing has flattened the variation it was meant to reveal. The layout still earns its place: weeks as columns and weekdays as rows means a weekend effect would show as two pale rows across the full width, and here it doesn't, which tells you something true about this person's week. The panel beside it is worse off. It is a four-axis diagram of code review, issues, pull requests and commits, and with 100% commits it collapses to a single straight line.
  • Calendar heatmap Fifty-three columns of weeks, seven rows of weekdays, and only Mon, Wed and Fri labelled to save the space.
  • KPI tile The total sits above the grid, so the cells have a denominator. Most calendar heatmaps omit this.
  • Sequential and diverging scales Five discrete steps rather than a continuous ramp, which is why ten commits and thirty share a colour.
  • Time-range picker Sixteen years as a list. No arbitrary range, no relative window—the only unit on offer is a calendar year.
Show 1 more example Hide the rest

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.