Skip to main content

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> reads anomaly as both the colour and the number it reports. This schema used to carry a constant stripe: 1 column 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

OptionWhat it doesReach for it when
A diverging ramp instead of sequentialCold and warm as two hues meeting at zeroThe quantity straddles a meaningful zero — which an anomaly does
useSequentialRamp(4)Fewer, wider steps, spread across the whole rampYou 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 = anomalyYou'd rather encode the value as height and keep colour as reinforcement
gap={1}Hairline separations between stripesThe reader should count years rather than read a gradient
cursor="region"Shades the whole stripe under the pointerThe stripe itself should light up, rather than a line running through it
<Marker> at a notable yearAn annotated callout in the annotation registerOne year needs naming — a Pinatubo dip, an El Niño peak

See also