Terra Rhythm

Build guide · Teaching artifact

How this essay
was drawn

Sticky-scene choreography, hand-rolled SVG charts, a computable colour system and a logged three-pass edit. Everything here is reproducible — that's the point.

01Concept & creative direction

Terra Rhythm is a scroll-driven data essay: a prose column advances while a pinned stage morphs through five hand-rolled SVG instruments — a radial year-wheel, an animated monsoon-front map, a farm-calendar gantt, a storm hyetograph and an onset scatter. The reader's scroll is the film projector; the charts are the frames.

The creative direction commits to the land it describes. The palette is dry-season dust, deep soil, rain blue, young green and storm slate — the five colours the essay is literally about. Gelasio (an essay serif) carries the prose; Rubik carries every annotation, axis and interface string, so "the voice" and "the instrument readings" are always typographically distinct.

One honesty rule governs everything: all data is illustrative and labeled as such — on the title scene, on every chart subtitle, in a full method note with the numbers published as tables. The essay teaches the shape of West Africa's rains, and never pretends to report measurements.

02Toolchain

Design & engineering
Fable 5 working as designer-engineer, directed by Hannah Kwakye
Stack
Hand-authored static HTML, CSS and vanilla JS — zero libraries, zero build step
Charts
Every chart generated as inline SVG by ~600 lines of chart code; no charting library
Fonts
Gelasio (roman + italic) and Rubik, self-hosted variable woff2, preloaded, font-display: swap
Assets
100% code-drawn: SVG charts and map, CSS gradients, an SVG-data-URI favicon. No photographs, no rasters
Motion
IntersectionObserver + one requestAnimationFrame lerp loop + CSS transitions; no scroll library
Deploy
Netlify, CI-driven from the repository; long-cache immutable headers on /assets/*

The code-drawn constraint is the collection's thesis, not a limitation: because the monsoon front is a path function rather than a video, it can wave, retreat, and respond to the reader — and the whole act weighs less than a single photograph.

03The signature: scroll choreography

The architecture separates discrete scene state from continuous motion, and uses the cheapest possible tool for each.

1 · One observer decides "which scene are we in?"

Every prose step is watched by a single IntersectionObserver whose root margin collapses the viewport to a thin band around its centre. Whichever step crosses that band becomes active — no scroll math, no thresholds to tune per section:

var stepIO = new IntersectionObserver(onStep, {
  rootMargin: '-42% 0px -42% 0px'   // a 16%-tall "now playing" band
});

2 · Discrete reveals are pure CSS

The active step's index is stamped on the stage as data-step. Annotation layers carry classes like s3 (only step 3) or from2 (step 2 onward), and CSS does the rest — including the fade timing:

.ann { opacity: 0; transition: opacity .5s ease .25s; }
[data-step="2"] .from2,
[data-step="3"] .from2,   /* cumulative: stays on after arriving */
[data-step="3"] .s3 { opacity: 1; }

One bug from Pass 1 is worth teaching: elements that carried their own opacity (the map's rain belt at 0.3) were silently overridden to fully opaque by these reveal rules. The fix is to keep the reveal channel (opacity) and the paint channel (fill-opacity) on separate properties.

3 · Continuous motion is one lerp loop

Anything that must glide — the wheel's bars growing, the front walking twelve degrees north — registers a named channel with a current value, a target and a render function. A single rAF loop eases every channel toward its target and stops itself when everything settles (and never even starts under reduced motion):

channels.month = { cur: 0, tgt: 0, k: 0.05, render: drawFront };
function tick() {
  var busy = false;
  for (var k in channels) {
    var c = channels[k], d = c.tgt - c.cur;
    if (Math.abs(d) > 0.0008) { c.cur += d * c.k; c.render(c.cur); busy = true; }
  }
  if (busy) requestAnimationFrame(tick);   // self-sleeping
}

Steps simply set targets: act II's "the climb" step says setTarget('month', 7) and the front glides from January to August by itself — scroll back up and it glides home.

4 · The chart math

The year-wheel's bars are annular sectors. Given inner/outer radii and start/end angles (0° at 12 o'clock, clockwise), the path is two arcs and two lines:

function annularSector(cx, cy, r0, r1, a0, a1) {
  var p1 = polar(cx,cy,r1,a0), p2 = polar(cx,cy,r1,a1),
      p3 = polar(cx,cy,r0,a1), p4 = polar(cx,cy,r0,a0);
  return 'M'+p1+' A'+r1+' '+r1+' 0 0 1 '+p2
       + 'L'+p3+' A'+r0+' '+r0+' 0 0 0 '+p4 + 'Z';
}

Each month occupies 30° and its bar uses 22° of it — the remaining 8° is the "surface gap" that separates marks without drawing borders. Rainfall maps to radius linearly against a 240 mm ceiling shared by both stations, so Kumasi's bars and Tamale's ring are read against the same scale.

The map is a tiny equirectangular projection — x = (lon+19)·29, y = 58+(21−lat)·26.5 — over a hand-authored, deliberately stylised coastline of 28 points. The monsoon front is a sine-wobbled polyline at the current latitude; the rain belt is the same path swept 6.4° south with a second wobble, clipped to the land polygon, with a fainter unclipped copy for the sea. Line "draw-ins" (the rain curve, Tamale's ring) are the classic stroke-dasharray/dashoffset trick; bar and dot reveals are CSS transform transitions with per-element delays.

04Dataviz craft (the part most essays skip)

Every chart obeys the same small rulebook, and the colour part was computed, not eyeballed. The brief's palette is atmospheric rather than chart-safe, so chart-duty variants were derived and run through a six-check palette validator (lightness band, chroma floor, colour-vision-deficiency separation, contrast against the actual surface):

  • #1F719F (chart blue) and #5E8032 (chart green) pass all six checks on the paper surface — CVD separation ΔE 70+, contrast ≥ 3:1. The raw brief hues failed (young green sat at 2.2:1).
  • #4899CE passes the same checks against storm slate for the dark act.
  • The Kumasi/Tamale pair is also shape-coded — bars versus line-with-dots — so identity never rides on colour alone.

Beyond colour: hairline solid gridlines, one axis per chart, direct labels only where the story points (the June peak, the 22 mm slice — never a number on every mark), a legend whenever two series share a stage, a plain-language takeaway under every scene, and a table-view twin for every dataset in the method note. The one dashed line in the whole essay is Accra's drain capacity — dashed because it genuinely is a threshold.

05Accessibility & reduced motion

  • Semantic landmarks, one h1, logical heading order, skip link, visible :focus-visible everywhere.
  • Each stage figure is role="img" with a full-sentence aria-label describing what the chart shows and where the numbers live; decorative layers are aria-hidden.
  • The takeaway line under each stage is aria-live="polite", so scene changes narrate.
  • prefers-reduced-motion: the lerp loop snaps instantly (each scene renders its final state), rain fields and idle drift are removed, transitions collapse to 0.01ms — and the prose still reads in order without any motion at all.
  • WCAG AA contrast throughout, both on paper and on the storm-slate act; every value is reachable as text via the data tables.

06Iteration log — three passes, as run

1Design critique (screenshots at 390/834/1440, read, fixed)

  • Found the reveal-opacity bug: the map's rain belt rendered as a solid slab because .ann reveal rules overrode its paint opacity — split paint onto fill-opacity and clipped the belt to the land polygon.
  • Label collisions everywhere the geometry got tight: "240 mm" vs "JAN" on the wheel, the Tamale annotation over "SEP", farm row labels clipped at the left edge, city rain streaks escaping their scene. Widened margins, moved annotations into a shared caption slot, bounded the rain animation in pixels.
  • Mobile layering was wrong — prose scrolled over the pinned stage. Raised the stage above steps on small screens so scenes stay a clean sticky theatre.

2Elevation (push what already works)

  • Gave the rain belt a second sine wobble on its southern edge and retired the harmattan arrows as the front walks past ~16°N — the map now reads as weather, not geometry.
  • Bumped SVG type sizes in viewBox units under 880px so axis text survives the ~0.55× mobile scale — charts stay genuinely readable on a phone, not decorative.
  • Added the storm "ledger" line (≈94 mm fell / ≈74 mm drained) and the wheel's caption-slot annotations so acts end on a quantified sentence.

3Ship quality

  • Zero console errors across all five acts at three viewports (a stray debug identifier in the map renderer was caught and removed here), and a step-through screenshot of every scene at 390/834/1440 — which caught Accra's bars arriving pre-grown in scene 1 (a missing reveal rule) and the wheel's zero-height bars ghosting as a dashed hub ring.
  • Reduced-motion verified: final states render, takeaways update, nothing animates; fonts load from /assets/fonts; every nav link, anchor, and cross-site link clicked.
  • Proofread the full essay copy and every axis, annotation, and table cell against the illustrative datasets.

07Performance & deploy

First view ships three woff2 files (~110 KB total), one stylesheet and one deferred script — the whole essay, charts included, transfers well under the collection's 250 KB budget because every visual is geometry, not pixels. The rAF loop sleeps whenever channels settle; the map's idle wobble runs only while its stage intersects the viewport; all reveal animation is transform/opacity so nothing thrashes layout.

Deploys ride Netlify's CI: push, build (there is nothing to build), publish. netlify.toml pins immutable year-long caching on /assets/* and the standard security headers on everything.

Want the thinking behind the design decisions — the options rejected, the trade-offs? That lives in the process case study.