Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Columns3 } from "lucide-react"
import { cn } from "@/lib/utils"
import type {
  ChartClimateStripesData,
  ClimateStripePeriod,
  ClimateStripesBaseline,
  ClimateStripesScale,
} from "./chart-climate-stripes.contract"

export interface ChartClimateStripesProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-climate-stripes.json

Prompt

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.radius is 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 idbaseline.window names 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 averagenull means 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.

On This Page