Blue ↔ gray ↔ red
Cool and warm poles that read as opposites, with a neutral gray at the baseline. Never a hue at the midpoint — a colored zero would imply zero means something, when it means "nothing yet."
One canvas, four movements, one dataset. A look inside the climate essay — the state machine that morphs warming stripes into a line into decade bars, the color decisions behind it, and the bug that made the chart lie for a day.
The brief asked for a climate scrollytelling essay: a warming-stripes hero that scroll pins while narrative steps recolor and re-annotate it — stripes, then a line, then decade bars, then a "your lifetime" highlighter driven by a birth year.
The concept I settled on treats that not as four charts but as one visualization read four ways. Nothing about the underlying numbers changes as you scroll — only the geometry we map them into. That constraint became the spine of the piece: a single pinned <canvas> and a single scroll value that eases the marks from one form into the next. The editorial frame — "The Anomaly Report, Vol. 145" — gave the restraint a voice: serious, print-adjacent, letting the data carry the color while the prose stays monochrome.
If the reader believes the stripes, the line, and the bars are the same 145 numbers, the essay has done its job — the warming isn't an artifact of how you draw it.
The page palette is a single warm paper (#f6f4ee) with near-black ink and one oxblood accent. The data stage inverts to near-black so the colored marks glow. Type is two families doing distinct jobs: Source Serif 4 for anything editorial (headlines, prose, the reading cards) and IBM Plex Sans / Mono for anything instrumental (axis ticks, telemetry, labels). The serif is the writer; the mono is the machine reporting numbers.
Temperature anomaly is a polarity encoding — every year sits above or below a baseline — so it takes a diverging ramp, not a sequential one. Per the skill's rules that meant two hues with a neutral gray midpoint, and the midpoint had to land exactly at zero:
Cool and warm poles that read as opposites, with a neutral gray at the baseline. Never a hue at the midpoint — a colored zero would imply zero means something, when it means "nothing yet."
The cold arm spans −0.49°C, the warm arm +1.28°C. Each arm normalizes to its own extreme so the deepest blue and deepest red both use the full ramp — the asymmetry of the record is the point.
Everything else obeys the skill's non-negotiables: a recessive hairline grid, a 2px line with rounded joins, direct labels instead of a color-only legend, and text that always wears ink tokens — never the series color. The diverging legend bar in the footer is the reader's key.
The whole piece is driven by one number: how far you've scrolled through the tall scrolly container, mapped to a continuous 0 → 3. The nearest integer picks which reading card is active; the eased float drives every pixel. There is exactly one source of truth, which is what keeps the chart, the telemetry, and the prose from ever disagreeing.
function scrollStage(){
const rect = scrolly.getBoundingClientRect();
const total = scrolly.offsetHeight - innerHeight;
const passed = Math.min(total, Math.max(0, -rect.top));
const frac = total > 0 ? passed / total : 0; // 0..1
stage = frac * (stepEls.length - 1); // 0..3 continuous
const active = Math.round(stage); // nearest card lights up
stepEls.forEach((s, i) => s.classList.toggle('is-active', i === active));
}
A render loop eases a second value, sAnim, toward stage each frame. From it I derive per-transition blend factors (f01, f12, f23), and each mark type fades in or out on its own factor. The stripes recede as the line arrives; the line's dots swell into decade bars; the bars fall away as the lifetime stripes return. No mark is ever hard-cut.
sAnim += (stage - sAnim) * 0.12; // critically-damped ease
const f01 = easeInOut(clamp(sAnim)); // stripes → line
const f12 = easeInOut(clamp(sAnim - 1)); // line → decade bars
const f23 = easeInOut(clamp(sAnim - 2)); // bars → your lifetime
const stripeAlpha = 1 - Math.min(1, f01 * 1.25); // stripes fade as line grows
The final movement returns to full-bleed stripes, then dims every year before the reader's birth to a third of its opacity and drops a labeled bracket at the birth line. It's a one-line conditional on top of the same stripe loop — the personalization is a filter, not a new chart.
for (let i = 0; i < n; i++) {
const inLife = YEARS[i] >= birthYear;
ctx.globalAlpha = a * (inLife ? 1 : 0.32); // dim the years before you
ctx.fillStyle = rgb(anomColor(VALS[i]));
ctx.fillRect(i * bw, 0, Math.ceil(bw) + 0.5, H);
}
Two more details earn their keep: the render loop pauses on document.hidden so a backgrounded tab burns no cycles, and prefers-reduced-motion snaps sAnim straight to stage — the morph still resolves, it just doesn't animate the in-between.
Every value is the NASA GISS Surface Temperature Analysis (GISTEMP v4) global annual mean land–ocean temperature index, 1880–2024, hardcoded to two decimals. The anomalies are measured against the 1951–1980 base period — the same baseline GISTEMP publishes against, chosen because it's recent enough to be well-observed and cool enough to make the modern rise legible.
The "1.5°" the Paris Agreement targets is measured against a pre-industrial reference, which sits roughly 0.2°C below GISTEMP's 1951–1980 baseline. So the essay's numbers and the Paris number are close cousins, not the same figure — and the piece says so in its sources rather than quietly conflating them.
The warming-stripes visual language is Ed Hawkins' (University of Reading, 2018); the implementation here is original — drawn mark by mark on canvas rather than adapted from his graphics. Full citations, including the GISTEMP uncertainty-model reference, live in the essay's footer.
Each pass was screenshot at multiple scroll depths and read back as if by a hostile design director. The middle pass caught the bug that mattered most.
The telemetry read "04 / YOUR LIFETIME" while the visible chart still showed decade bars, and the lifetime stripes bled on top of the bars. The cause: two scroll drivers. I had wired both an IntersectionObserver on the step cards and a continuous scroll fraction, and they disagreed about which stage was current — one snapping, one gliding.
The fix was to delete the observer entirely and derive everything — active card and morph target — from the single scroll fraction shown above. One source of truth, no possible disagreement.
With the stages honest, the crossfade windows still overlapped: decade bars were fading out at the same time lifetime stripes faded in, so both drew at once. I retimed it — bars gone by f23 = 0.35, stripes not starting until then. The axis grid now fades out cleanly in the stripe stages instead of ghosting behind them.
The reading panel became a slim mono telemetry strip pinned bottom-left, out of the reading cards' path; decade bars gained a crisp top edge for contrast against the dark stage. Complexity added: a film-grain overlay that gives the stage a printed-plate texture.
At 390px the decade labels piled into an illegible heap, so they're now suppressed whenever a bar is under 34px wide — the telemetry strip already carries the value. Complexity added: a proper hover crosshair and floating year·anomaly tag on the line chart, honoring the dataviz skill's rule that an interactive chart ships a tooltip by default.
Final pass: zero console errors at every scroll depth on both 1440×900 and 390×844, and no horizontal body scroll.