Data
Heatmap
A grid of values read by weight.
Usage
When to reach for it
Ships from@misoto22/design/charts
import { Heatmap } from '@misoto22/design/charts'Notes
A grid of values read by weight — a calendar of activity, a confusion matrix, an hour-by-weekday load.
The one form in the set that a monochrome system renders BETTER than a chromatic one. A heatmap needs a scale whose order the eye can read without consulting a legend, and lightness is the only channel that is unambiguously ordered — which is why "one hue, light to dark" is the standing advice everywhere else, and why the rainbow heatmap is a perennial mistake. Here there is no hue left to get wrong.
It is built as a real <table> rather than as an SVG, so the structure a screen reader walks is the structure the eye reads: a header row, a header column, and a cell that announces its own value. Nothing else in the package can say that, and nothing else needs to — which is also why this one takes no rendering engine at all.
Anatomy
| Element | Description |
|---|---|
| Figure captionrequired | title in a figcaption, hidden from sight unless showTitle prints it above the grid with description under it. The same string is also the table’s own sr-only caption, so the grid is named on the way in and again from the table. |
| Column headersrequired | One th scope="col" per entry in columns, in the order given, preceded by an sr-only corner cell reading “Row” so the header line has something standing over the row labels. |
| Row headersrequired | One th scope="row" per entry in rows — end-aligned mono at --chart-axis, so every cell can be traced back to the pair of labels that names it. |
| Cellrequired | A td whose background is a color-mix of --series-1 into --chart-surface at the value’s position in the domain. Continuous rather than stepped, because banding a ramp invents boundaries the data does not have, and 2px of border-spacing between cells is what makes two neighbouring weights countable. |
| Missing cell | What a null draws — and what a row and column pair absent from cells draws too: a dashed --rule outline over the page, announcing “no data” instead of a number. |
| Printed value | formatValue inside the cell. Visible with showValues, otherwise sr-only and un-hidden again under forced colours, where the wash that WAS the encoding has been remapped away. |
Best practices
Do
- Switch scale to diverging the moment the values cross zero. sequential ramps on POSITION in the domain, so on a grid of changes the deepest fall is the palest cell and the biggest rise the darkest; diverging ramps on distance from the midpoint instead, which is the read a delta actually wants.
- Pin domain when one cell is an order of magnitude clear of the rest. The derived domain runs from zero — or the lowest value, when that is negative — to the highest, so a single 10,000 among readings of 5 to 40 puts every other cell inside the first half percent of the ramp, and forty distinct numbers render as one dark square on a blank grid.
- Spell a cell’s row and column exactly as they appear in rows and columns: cells are looked up by that pair, so an unmatched entry draws nothing at all. It no longer counts toward the derived domain either — a typo used to stretch the ramp with a value that appeared nowhere on the grid, pushing every drawn cell into the first fraction of it.
- Take the contrast trade knowingly when showValues goes on. The wash is capped at 35 percent of the ramp so one ink colour clears 4.5:1 on every cell — the ORDER of the cells survives untouched, the spread between them narrows, and the printed numbers become the thing carrying the detail.
Don’t
- Do not let a pipeline fill gaps with zero before the grid sees them. A null is drawn as a dashed outline and announced as no data, and a zero is drawn as the palest cell on the ramp — so substituting one for the other turns an outage into a quiet hour, and nothing on the grid says which it was.
- Do not expect a reader to recover a figure from the wash. There is no legend and no step: weight orders the cells and never states one. The forced-colours fallback un-hides the numbers because the background has stopped existing there, which is a rescue for a remapped ground rather than a substitute for showValues on a grid whose exact figures matter.
Examples
default
The sequential scale, for a value that only ever goes up — a count, a duration, a volume. The ramp is one hue, light to dark, because lightness is the only channel whose order the eye reads without consulting a legend; spending hue on magnitude is the perennial heatmap mistake, and there is no hue left here to get wrong. The domain is derived from the cells, which is right for a grid read on its own and wrong the moment a second grid is put beside it.
| Row | 00 | 03 | 06 | 09 | 12 | 15 | 18 | 21 |
|---|---|---|---|---|---|---|---|---|
| Mon | 8Mon, 00 | 8Mon, 03 | 32Mon, 06 | 53Mon, 09 | 67Mon, 12 | 72Mon, 15 | 67Mon, 18 | 53Mon, 21 |
| Tue | 8Tue, 00 | 8Tue, 03 | 32Tue, 06 | 53Tue, 09 | 67Tue, 12 | 72Tue, 15 | 67Tue, 18 | 53Tue, 21 |
| Wed | 8Wed, 00 | 8Wed, 03 | 32Wed, 06 | 53Wed, 09 | 67Wed, 12 | 72Wed, 15 | 67Wed, 18 | 53Wed, 21 |
| Thu | 8Thu, 00 | 8Thu, 03 | 32Thu, 06 | 53Thu, 09 | 67Thu, 12 | 72Thu, 15 | 67Thu, 18 | 53Thu, 21 |
| Fri | 8Fri, 00 | 8Fri, 03 | 32Fri, 06 | 53Fri, 09 | 67Fri, 12 | 72Fri, 15 | 67Fri, 18 | 53Fri, 21 |
| Sat | 2Sat, 00 | 2Sat, 03 | 9Sat, 06 | 15Sat, 09 | 19Sat, 12 | 20Sat, 15 | 19Sat, 18 | 15Sat, 21 |
| Sun | 2Sun, 00 | 2Sun, 03 | 9Sun, 06 | 15Sun, 09 | 19Sun, 12 | 20Sun, 15 | 19Sun, 18 | 15Sun, 21 |
diverging
Diverging reads distance from the midpoint, so both directions darken and the middle of the scale is the page itself — the scale for a delta, a residual, anything whose zero is a real boundary rather than just the bottom. The domain is pinned symmetrically on purpose: derived, it would run from the lowest reading to the highest and leave the midpoint wherever the extremes happened to fall rather than on the zero the scale hinges on. The week with no reading is drawn as a dashed outline instead of the palest cell, because a gap is not a zero, and showValues caps the wash at 35 percent of the ramp so a printed number clears its own background on every cell.
| Row | W1 | W2 | W3 | W4 | W5 | W6 |
|---|---|---|---|---|---|---|
| Platform | 0 | 34 | 36 | 6 | -30 | -38 |
| Growth | 40 | 17 | -21 | -40 | -22 | 16 |
| Mobile | -10 | -38 | -31 | Mobile, W4: no data | 36 | 34 |
| Data | -37 | -7 | 29 | 39 | 13 | -25 |
shared domain
Two grids that only mean something read against each other, so both are pinned to one domain. Derived, each would run from zero to its own largest reading and both would use the full ramp — Frankfurt's three-minute peak would come out exactly as dark as Sydney's ten-minute one, which is the single failure a shared legend cannot fix. formatValue carries the unit into the cells: without it a shift announces itself as 480, and with it as 8m, which is the difference between a number and a reading for anyone reaching these cells through the table rather than by eye.
| Row | 00–06 | 06–12 | 12–18 | 18–24 |
|---|---|---|---|---|
| Mon | 45sMon, 00–06 | 3m 30sMon, 06–12 | 8mMon, 12–18 | 4m 20sMon, 18–24 |
| Tue | 50sTue, 00–06 | 4mTue, 06–12 | 8m 40sTue, 12–18 | 4m 35sTue, 18–24 |
| Wed | 1mWed, 00–06 | 4m 20sWed, 06–12 | 8m 25sWed, 12–18 | 5mWed, 18–24 |
| Thu | 55sThu, 00–06 | 4m 10sThu, 06–12 | 9mThu, 12–18 | 5m 20sThu, 18–24 |
| Fri | 1m 10sFri, 00–06 | 5mFri, 06–12 | 9m 50sFri, 12–18 | 5m 55sFri, 18–24 |
| Row | 00–06 | 06–12 | 12–18 | 18–24 |
|---|---|---|---|---|
| Mon | 30sMon, 00–06 | 1m 30sMon, 06–12 | 2m 40sMon, 12–18 | 2mMon, 18–24 |
| Tue | 35sTue, 00–06 | 1m 45sTue, 06–12 | 3mTue, 12–18 | 2m 10sTue, 18–24 |
| Wed | 40sWed, 00–06 | 1m 50sWed, 06–12 | 2m 55sWed, 12–18 | 2m 20sWed, 18–24 |
| Thu | 30sThu, 00–06 | 1m 35sThu, 06–12 | 2m 45sThu, 12–18 | 2m 5sThu, 18–24 |
| Fri | 45sFri, 00–06 | 2mFri, 06–12 | 3m 20sFri, 12–18 | 2m 30sFri, 18–24 |
Types
export type HeatmapScale = 'sequential' | 'diverging'Accessibility
- A real <table>, not an SVG: the structure a screen reader walks is the structure the eye reads, and every cell announces its own row, column and value.
- Lightness is the only channel that is unambiguously ordered, which is why the standing advice everywhere else is “one hue, light to dark”. Here there is no hue left to get wrong.
- A null is drawn as a dashed outline, never as the palest cell — a missing reading is not a zero.
- Pin domain whenever two grids are compared: on independent domains they look alike and mean different things, which is the one failure a shared legend cannot fix.