Climate Stripes
One stripe per period coloured by its distance from a baseline — a symmetric diverging ramp, a reference window that can be pinned so two strips compare, anchor labels, and unmeasured periods kept apart from average ones.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-climate-stripes.jsonPrompt
Build a React + TypeScript + Tailwind "Climate Stripes" component (zod, lucide-react, a cn() class merger).
One coloured stripe per period, coloured by value minus a baseline, so a century of a single measure
reads as one strip with no axes and no gridlines.
Contract
- zod schema, one source of truth:
status: "loading" | "empty" | "error" | "ready"
periods: { id: string; label: string; value: number | null }[] // ARRAY ORDER IS THE AXIS
baseline: {
kind: "mean" | "median" | "value" // an average of a reference window, or a number you committed to
value?: number // required by kind "value" (an SLA target, a budget)
window?: { from: string; to: string } // inclusive id span of the reference periods, e.g. 1961-1990
label?: string // printed as the neutral point's name
}
scale: { radius?: number; unit?: string } // radius = pinned HALF-WIDTH of the ramp; omit to fit
- Props on top of the contract: title, description, variant ("full" | "split", default "full"),
highlight (period id to call out), maxLabels (2-24, default 8), minStripeWidth (1-40px, default 4),
bandHeight (32-400px, default 112), decimals (0-6), locale (default "en-US"), selectableArms
(default true), skeletonStripes (8-600), onRetry, emptyState, className, ...div props. forwardRef.
- Export the arithmetic as pure functions beside the component so a test can print the same numbers the
picture is made of: buildClimateStripesLayout(periods, baseline, scale) -> cells + baseline + radius +
counts, pickAnchorIndices(count, max), stripeBox(sign, variant), stripeMix(t).
Behavior
- Four rules decide whether the picture is honest:
1. The ramp is a RADIUS, never a [min, max] pair. r = scale.radius when pinned, else max|value - baseline|.
Depth t = |value - baseline| / r, clamped to 1. +0.8 and -0.8 therefore land on the same depth on
opposite arms. Normalising a diverging measure as (v - min)/(max - min) instead paints the coldest
period in the neutral colour and is the classic way to publish a wrong strip.
2. Fitted vs pinned is a real decision, so print which one is in force. Fitted is the default because
most strips are read alone; pin the radius the moment two strips are read against each other, or a
calm century and a violent decade paint the same picture. Anomalies past a pinned radius clamp onto
the darkest step and are COUNTED in a visible note - never trimmed in silence.
3. The baseline is data, not styling. Resolve baseline.window BY ID (so it survives a series that grows
at either end), take the mean/median of the measured values inside it, and fall back to the whole
series - saying so on screen - when either id is unknown or the window holds no measurement.
kind "value" is the only baseline that does not move when next period's number lands.
4. null is not "average". A declared null keeps its slot and is drawn as a full-height hatch with no
colour at all; a NaN / Infinity / non-number becomes the same hatch AND is counted as refused. A
period simply absent from the array shortens the strip - that is a different statement, and the
contract docs must say so.
- Never colour alone. Sign rides on three channels at once: hue (two chart tokens), a hatch on the
below-baseline arm, and in variant "split" the half of the band the stripe inks. The extremes are also
direct-labelled in text under the strip ("Furthest above: 2024, +1.28"), so the two facts a reader
takes away survive greyscale.
- Anchors, not an axis: pickAnchorIndices spaces at most `maxLabels` labels evenly and always includes the
first and last period. Labels at the ends hug the edge instead of centring (a centred first label is
clipped by the scroll pane), long labels elide with a title attribute, and `highlight` gets its own tick
and printed label above the strip.
- Keyboard is a first-class path, never a gesture-only one: the strip is ONE tab stop with a roving
tabindex. Left/Right walk a period, PageUp/PageDown jump ten, Home/End reach the ends; movement clamps
and never wraps. Pointer and focus feed the same readout line under the strip. Focus moves by calling
.focus() on a tabIndex -1 stripe, so the move never waits for a render, and after a refresh that deletes
the focused stripe the tab stop is re-seated instead of dropping focus on <body>.
- The two legend arms are toggles that isolate one side of the baseline (the other dims). Use aria-pressed
plus aria-disabled with a handler guard for an empty arm - never the native disabled attribute, which
would remove from the tab order the very fact that the arm is empty. Isolation changes stripes far from
the button, so announce it in a polite live region.
- Four states are first-class branches. loading draws skeleton stripes announced once by an sr-only
status, empty and error are centred messages, error shows retry only when onRetry exists. Ready with
zero measured periods falls through to the empty branch WITH the refusal note attached.
- Deterministic and SSR-stable: no Math.random, no Date.now, no clock read during render. The called-out
period is an injected id.
- Degenerate data must not break the geometry: an empty array, one period, every measured value identical
(r = 0, so every stripe is neutral and a line says why), all-null, a series longer than the 600-period
cap (truncated and counted), labels far too long for the rail.
Rendering & styling
- Plain flex boxes, no SVG: each period is a flex-1 div holding one absolutely positioned painted child,
so the strip is seamless at any container width and needs no ResizeObserver. The scroll pane's inner
width is count * minStripeWidth, which is what makes a 600-period strip scroll instead of collapsing to
hairlines.
- Semantic tokens only. Depth is `color-mix(in oklab, var(--chart-5) X%, var(--card))` above the baseline
and `var(--chart-1)` below it, X ramping 7% -> 84% through one shared function so both arms are mixed at
the same strength. Mixing into the surface makes both ramps travel AWAY from it, so the strip is legible
in light and dark without a second palette. The hatch alternates one stripe toward `--foreground` and one
toward `--card` at equal strength, marking the sign without adding depth. Missing periods get the
muted-foreground hatch and no background colour at all.
- role="grid" with one row of role="gridcell" stripes, aria-colindex per stripe, aria-labelledby on the
heading and aria-describedby on an sr-only one-sentence summary (count, window, measured, range,
baseline, ramp). Anchor and highlight rails are aria-hidden: every stripe already announces its own
period. Axis text is text-muted-foreground text-xs; ticks and the split centre line use border tokens.
- prefers-reduced-motion: the only animations are the loading pulse (motion-reduce:animate-none) and the
dimming transition (motion-safe). The chart is complete with animation off.
Customization levers
- Colour: swap the two tokens for the ends your theme owns, and swap them the other way round whenever
ABOVE is the bad direction (an error rate, a cost, a queue). Keep ONE mix ramp for both arms so equal
magnitudes stay equally deep - no two hues carry identical lightness, which is exactly why the hatch and
the split variant exist. Collapse both to a single token when the strip must read in greyscale. Raise
MAX_MIX for a heavier strip, lower MIN_MIX to keep near-baseline periods nearly blank.
- Emphasis curve: apply a gamma to t in fillStyle (t ** 0.7 spreads the quiet end for a series whose
interesting years are rare); for a long-tailed measure, transform upstream and pin the radius in the
transformed space.
- Density: bandHeight plus minStripeWidth are the two knobs. 40-64px with 2-3px stripes is a report
ribbon; 120-160px with 8px stripes is a hero figure you can point at.
- Trim: selectableArms={false} for a static report (the legend keeps its printed bounds and the strip
keeps its keyboard), maxLabels={2} for first and last only, emptyState to replace the zero state
wholesale, drop the peaks line if the strip sits next to a table that already names them.
- Variant: "full" is the classic strip; "split" spends half the band on making the sign unmistakable and
is the better default for a projected slide or a print report.
- Period: nothing about it is climatic - months against an SLA target, quarters against plan, sprints
against a velocity baseline. Only `label` and `baseline.label` need to change.Concepts
- Anomaly, not value — a stripe encodes distance from a reference, so the reference is part of the data contract and is printed under the strip. Change the baseline and every stripe changes; a strip whose baseline is off-screen is a picture nobody can check.
- Radius, not range — the diverging ramp is resolved as a half-width around the baseline, which is what forces the two arms onto one scale. It is structurally impossible for this component to draw an asymmetric ramp, whatever it is handed.
- Pinned vs fitted — fitted fills the ramp with whatever this series happens to contain and is right for a strip read alone. Pinning
scale.radiusis what makes two strips comparable, and periods past a pinned edge clamp visibly and get counted rather than quietly flattening the rest. - Reference window by id —
baseline.windownames the first and last reference period instead of their positions, so appending next year cannot shift the normal. An unresolvable window falls back to the whole series and says so, because a silent fallback moves every stripe. - Missing is not average —
nullmeans nothing was measured; the baseline colour means "measured, and exactly typical". They are opposite claims, so a gap gets a hatch, no colour, and a line in the notes. - Sign on three channels — hue, a hatch on the cool arm, and in the split variant the half of the band that gets inked. Any one of them can be taken away — printed in greyscale, projected, read by someone who cannot separate the hues — and the strip still says which side of the baseline a period fell on.
Trace Waterfall
A four-state request waterfall — nested spans on one shared relative clock, phase segments inside each bar, self-placing duration labels, an interval-attributed critical path ruled over exactly the stretch each span was blocking, and a collapsible tree the arrow keys can walk.
Event Timeline
A four-state timeline of instants — one lane per track, a shape per kind, marks that crowd into one counted cluster instead of overlapping, brush-or-keyboard zoom into any window, and a now marker from an injected instant.