Climate stripes
146 cells, all exactly the same height, where the colour is the value. It's
the inverse of every other chart in this gallery: position carries nothing but
time, and the entire quantitative channel is hue. The form is Ed Hawkins'
"warming stripes" (University of Reading, 2018); the layer underneath it is
<HeatMap>, and it works for anything you'd otherwise draw as a heat strip.
The chart
Point at a stripe and the strip above the chart names its year and its anomaly.
That readout is this chart's legend — with the value encoded as colour and
no y-axis to read, it is where a number comes back out. It used to be the
only place one could: the bars carried no value at all, so the anomaly had to
be fetched from the fixture by year. <HeatMap> changed that — the cell
reports itself — and Build it explains why the strip stays beside
the plot anyway, which is the most transferable thing on this page.
What the shape says is one-way traffic across the ramp. The darkest of the eight steps has not appeared since 1933; the darkest two, not since 1976. Going the other way, the lightest step first appears in 2023 and every year from 2015 onward sits in the top two. 1909 is the coldest year in the record at −0.49 °C and 2024 the warmest at +1.28 °C.
The data
Real, measured, public domain. NASA's
GISS Surface Temperature Analysis (GISTEMP v4),
land-ocean temperature index, annual means — the J-D column of
GLB.Ts+dSST.csv. A NASA work, so public domain; NASA asks for attribution and
this page gives it. Retrieved by
website/scripts/fixtures/weather.mjs,
which runs by hand and commits its output.
[time, anomaly]; // year start, °C vs the 1951–1980 mean
- 146 complete years, 1880–2025. Only complete years are kept, so the current partial year is absent — an annual mean over eight months isn't the same quantity as an annual mean, and plotting it as one puts a lie at the end of the record.
- The values are anomalies, not temperatures: °C relative to the 1951–1980 average. That's why the scale straddles zero, and why "−0.49" is a perfectly ordinary global mean rather than an ice age.
- GISTEMP revises history. As station records are homogenised, older values move slightly. That's why the fixture header records a retrieval date; a regenerated fixture will not be bit-identical to this one.
- One value column is all it needs.
<HeatMap>readsanomalyas both the colour and the number it reports. This schema used to carry a constantstripe: 1column too, so that the bars drawing the stripes were full height — a column carrying no information, which had consequences for the cursor. The heat map removed the need for it.
Build it
The encoding is the layer's job now, so there is no per-bar colour array to
build. Hand <HeatMap> the column and the ramp:
const ramp = useSequentialRamp(); // 8 theme colours, dark → light
<ChartContainer range={[FIRST, LAST]} width={640} theme={theme}>
<ChartRow height={200}>
{/* One row, so the layer's own [0, 1] extent is the whole plot. Empty
`ticks` and zero width take the axis off the canvas: a single unnamed
row needs no scale drawn. */}
<YAxis id="stripe" width={0} ticks={[]} />
<Layers>
<HeatMap
series={series}
columns={['anomaly']}
colors={ramp}
axis="stripe"
gap={0}
/>
</Layers>
</ChartRow>
</ChartContainer>;
columns is the y dimension: one column per row, so a single name is a
single-row stripe. The value domain is split into ramp.length equal bands
and each cell takes its band's colour — eight steps across the record's
1.77 °C spread, so each step is about 0.22 °C. The colours come from
useSequentialRamp(), the site's own ramp, so they follow the light/dark
toggle; swapping in Hawkins' blue-to-red diverging scale is the same one line,
and for a quantity that straddles zero a diverging scale is arguably the better
choice. The rule this site follows is one hue family rather than competing
hues.
gap={0} is what makes it a continuous strip rather than a grid of tiles — the
cells take their width from the spacing of their neighbours, so they tile edge
to edge with no seams.
Why the readout is still off-chart
This card reads its number out beside the plot rather than in a cursor pill, and the reason changed when it became a heat map.
It used to be that it couldn't: the crosshair's pill reads the column the
layer draws, which was the constant stripe, so every bar reported 1.0. The
anomaly had to be fetched from the fixture by year.
Now the cell carries its value, and onTrackerChanged hands it over directly:
const [hit, setHit] = useState<{ year: number; anomaly: number } | null>(null);
<ChartContainer
onTrackerChanged={(info) => {
const sample = info?.values[0];
setHit(
info === null || sample === undefined
? null
: {
year: new Date(info.time).getUTCFullYear(),
anomaly: sample.readout ?? sample.value,
},
);
}}
>
Note readout ?? value. A TrackerSample's value is where the cursor
draws — the pill is placed at yScale(value) — and a cell's number is not a
y coordinate: the y axis here is one unit-high row, so placing a pill at
yScale(-0.02) would put it outside the plot. So the heat map reports the row's
centre as value and the cell's number as readout, which is exactly the split
readout was added for.
That is why the strip stays outside the plot: not because the number is unavailable any more, but because it is a legend for a colour scale, and a legend belongs next to the chart rather than chasing the pointer.
This generalises past climate stripes. Any time the visual channel isn't the
column you want to read — a colour encoding, a categorical mark, a chart
whose drawn value is a layout device — the in-chart pill is the wrong tool and
onTrackerChanged is the door out.
Options to try
| Option | What it does | Reach for it when |
|---|---|---|
| A diverging ramp instead of sequential | Cold and warm as two hues meeting at zero | The quantity straddles a meaningful zero — which an anomaly does |
useSequentialRamp(4) | Fewer, wider steps, spread across the whole ramp | You want a coarser reading, or the eight steps aren't distinguishable at size |
Draw anomaly and drop the width={0} | Puts a real value axis back, bar height = anomaly | You'd rather encode the value as height and keep colour as reinforcement |
gap={1} | Hairline separations between stripes | The reader should count years rather than read a gradient |
cursor="region" | Shades the whole stripe under the pointer | The stripe itself should light up, rather than a line running through it |
<Marker> at a notable year | An annotated callout in the annotation register | One year needs naming — a Pinatubo dip, an El Niño peak |
See also
<BarChart>—binColors, gaps, and the dense-bar path- Theming — roles, ramps, and why there are no literals above
- Cursors and readouts — the cursor modes, and
onTrackerChanged - Value axes — pinning and suppressing an axis
- Storybook — the systematic knob walk