Charts
Heatmap
How much of something happened per day, across weeks. A contribution field, a load calendar, a run count. The one reading here whose axis is intensity rather than a category or an outcome.
Markup
<div class="inst-heatmap" role="img"
aria-label="Thirteen weeks of runs: busiest in the last fortnight, at 40 a day; nine quiet days in all">
<span class="inst-heatmap-cell" data-empty></span>
<span class="inst-heatmap-cell" data-empty></span>
<span class="inst-heatmap-cell"></span>
<span class="inst-heatmap-cell" data-level="1"></span>
<span class="inst-heatmap-cell" data-level="2"></span>
<span class="inst-heatmap-cell" data-level="1"></span>
<span class="inst-heatmap-cell" data-level="3"></span>
<span class="inst-heatmap-cell"></span>
<span class="inst-heatmap-cell" data-level="2"></span>
<span class="inst-heatmap-cell" data-level="4"></span>
<span class="inst-heatmap-cell" data-level="1"></span>
<span class="inst-heatmap-cell" data-level="3"></span>
<span class="inst-heatmap-cell" data-level="2"></span>
<span class="inst-heatmap-cell"></span>
<span class="inst-heatmap-cell" data-level="1"></span>
<span class="inst-heatmap-cell" data-level="4"></span>
<span class="inst-heatmap-cell" data-level="3"></span>
<span class="inst-heatmap-cell" data-level="2"></span>
<span class="inst-heatmap-cell" data-level="1"></span>
<span class="inst-heatmap-cell"></span>
<span class="inst-heatmap-cell" data-level="3"></span>
<span class="inst-heatmap-cell" data-level="4"></span>
<span class="inst-heatmap-cell" data-level="2"></span>
<span class="inst-heatmap-cell" data-level="1"></span>
<span class="inst-heatmap-cell" data-level="3"></span>
<span class="inst-heatmap-cell"></span>
<span class="inst-heatmap-cell" data-level="2"></span>
<span class="inst-heatmap-cell" data-level="4"></span>
<span class="inst-heatmap-cell" data-level="3"></span>
<span class="inst-heatmap-cell" data-level="1"></span>
<span class="inst-heatmap-cell" data-level="2"></span>
<span class="inst-heatmap-cell" data-level="4"></span>
<span class="inst-heatmap-cell" data-level="4"></span>
<span class="inst-heatmap-cell" data-level="3"></span>
<span class="inst-heatmap-cell" data-level="2"></span>
<span class="inst-heatmap-cell" data-level="1"></span>
<span class="inst-heatmap-cell" data-level="4"></span>
<span class="inst-heatmap-cell" data-level="3"></span>
<span class="inst-heatmap-cell" data-level="2"></span>
<span class="inst-heatmap-cell" data-level="4"></span>
<span class="inst-heatmap-cell" data-level="1"></span>
<span class="inst-heatmap-cell" data-level="3"></span>
<span class="inst-heatmap-cell" data-level="4"></span>
<span class="inst-heatmap-cell" data-level="2"></span>
<span class="inst-heatmap-cell" data-level="3"></span>
<span class="inst-heatmap-cell" data-level="4"></span>
<span class="inst-heatmap-cell" data-level="1"></span>
<span class="inst-heatmap-cell" data-empty></span>
<span class="inst-heatmap-cell" data-empty></span>
</div>
Contract
| What | Required | Why |
|---|---|---|
A role="img" and an aria-label with the quantity in words |
yes | Ninety empty <span>s are ninety empty <span>s to a screen reader. Here the name is the whole of the accessible content: unlike a badge or a change mark, a cell has no sign inside it to fall back on |
| Seven rows | yes | The grid is declared with seven rows and the cells flow down them by column. A different number is a different picture and wants a different component |
| The freshest at the right | yes | The field is read as time, and time in this script runs to the right — the same rule as the check history |
| A level from the closed set | yes | 1 · 2 · 3 · 4, with no attribute for the empty ground. A fifth value silently does nothing |
A data-empty for days outside the range |
no | Only when the range starts or ends mid-week |
Accessibility
| Colour is the only carrier, and here that is honest | Intensity has no shape to fall back on: a cell twice as dark is not a cell of a different form. Everything else in the kit that colours a mark also gives it a sign or a word; this cannot, so the burden moves entirely to the accessible name |
| The name | It has to state the quantity, not the shape. "Thirteen weeks of runs, busiest in the last fortnight at 40 a day" is a name; "activity heatmap" is not |
| Forced colours | There is no rule, deliberately. One fill is left in that mode, and a grid of identical squares would go on claiming to mean something it no longer does — the same decision as on the check history |
| The role | img, not table. There are no headers and nothing is read cell by cell; it is one picture with one name |
| Contrast | The steps are marks rather than text and are not held to 4.5:1. What matters is that neighbouring steps are told apart, which the mix guarantees by construction: each is a quarter of the distance from the ground to the accent |
Variants
The four steps are mixed, not declared:
background: color-mix(in oklab, var(--accent-solid) 25%, var(--surface-recessed));
--chart-1..6 are the wrong palette for this and could not be used. They are
categorical — chosen so that series are told apart — and a ramp needs the
opposite: steps that are plainly the same colour at different strengths.
Declaring five new tokens instead would mean five values across fifteen scale
and density cells and four accents, re-tuned every time the accent moved. A
mix from --accent-solid down into the empty ground follows all four accents
and all five themes for free, and the empty cell takes --surface-recessed —
the same fill a metric tile sits on.
| Level | Fill |
|---|---|
| no attribute | --surface-recessed |
data-level="1" |
25% accent |
data-level="2" |
50% accent |
data-level="3" |
75% accent |
data-level="4" |
--accent-solid |
Four steps and no more. Five was tried on screen and the middle two stop being told apart at a cell of 14px; the eye reads a heatmap by area, and area needs coarse steps.
Sizes
The cell defaults to --size-icon-sm — 14px, against the reference's measured
14.7 — and rides the type ladder, so the field grows with the interface rather
than staying a fixed square while everything round it moves.
<div class="inst-heatmap" style="--cell-size: 10px" role="img" aria-label="…">
A year is fifty-two columns and will not fit a narrow panel. The field pans with the scrollbar hidden — the device the tab strip and the status bar both use. It does not wrap: a wrapped heatmap is two pictures.
API
| Name | What it does |
|---|---|
| class | |
inst-heatmap | The field. Seven rows, weeks running along the inline axis, freshest at the right |
inst-heatmap-cell | One day. A square mark, and nothing else goes inside it |
| Name | Value | What it does |
|---|---|---|
| attribute | ||
data-level | 1 · 2 · 3 · 4 | The intensity of the day. With no attribute the cell is the empty ground: the day happened and nothing was counted on it |
data-empty | — | There is no such day — the range starts mid-week or ends mid-week. The room stays, the mark does not |
role | img | Required. The field is a picture of data and has to have a name |
aria-label | — | Required, and it has to carry the quantity in words. Colour is the only carrier in the picture itself |
| Name | Value | Where from |
|---|---|---|
| variable | ||
--cell-sizeThe side of a cell. Set it on the field to make the squares larger or smaller | --size-icon-sm | the application sets it |
| token | ||
--size-icon-smThe default side of a cell. The reference's measures 14.7 | 14px | base +4: scale |
--accent-solidThe top of the ramp. The steps below it are mixed down into the empty ground | var(--a-4) | one across every cell |
--surface-recessedThe empty cell, and the ground every step is mixed into | light-dark(oklch(0 0 0 / 0.060), oklch(1 0 0 / 0.050)) | one across every cell |
--radius-mark | var(--radius-2xs) | one across every cell |
--space-1 | 2px | one across every cell |
Why the kit works this way — the constitution · Open source under MIT, github.com/keshon/instrument