Weather.Co is free for CFIs and flight schools — always.
The Lab · open hangar · v1.0

The Lab.

A working showcase of modern web techniques. Each specimen below is a live demo with the engineering notes, the essential code, and a specs sheet — instrumented so you can see it working in real time.

Viewport
— × —
DPR
Color depth
Refresh rate
measuring…
Cores
Connection
Reduced motion
Touch
No. 01Pointer-tracked light · GPU compositor

A spotlight that follows you.

Three independent layers — a soft light, a masked grid, and a page-wide glow — each react to one input. Move your cursor anywhere over the panel and watch the readout in the corner update at your refresh rate.

x0 y0 Δcenter0px events/s0
Hello, you.
Move your cursor anywhere

How it works

A single pointermove handler writes two CSS custom properties — --sx and --sy — onto the stage element. Everything visible is rendered by the GPU compositor from those two values:

Layer 1 (the light): a radial-gradient centered at (--sx, --sy) painted in a ::before pseudo-element.
Layer 2 (the grid): a CSS background-image grid revealed only where a second radial gradient mask permits — also centered on the cursor.
Layer 3 (the page glow at the top of this page): the hero background reads the same coords mapped to viewport %.

JavaScript never paints anything. The handler runs in well under 0.1ms; the compositor handles the rest.

Trade-offs

  • GPU-accelerated — no layout, no paint thrash
  • Vanishingly small JS footprint (~12 lines)
  • Works on every browser shipped since 2016
  • Mask sampling is per-pixel — large stages cost real GPU time
  • Touch devices have no hover; degrade with a fallback animation

The essential code

spotlight.jsjavascript
// One handler. Two custom properties. Zero canvas calls.
stage.addEventListener('pointermove', e => {
  const r = stage.getBoundingClientRect();
  stage.style.setProperty('--sx', (e.clientX - r.left) + 'px');
  stage.style.setProperty('--sy', (e.clientY - r.top)  + 'px');
}, { passive: true });
spotlight.csscss
.stage::before {                            /* the light */
  background: radial-gradient(
    420px at var(--sx) var(--sy),
    rgba(58,143,255,.55), transparent 60%
  );
}
.stage::after {                             /* the grid + mask */
  background-image: /* 1px grid */;
  mask: radial-gradient(380px at var(--sx) var(--sy), #000, transparent 75%);
}

Specs

Bytes (gz)
~340 (CSS+JS)
Frame budget
< 0.4ms
GPU accel
Yes — compositor only
Browser support
98% global
Touch fallback
Static center ~
No. 02Liquid Glass surface · backdrop-filter

Depth through blur, not boxes.

Apple's design language without Apple's hardware. The conic gradient drifts underneath; the glass cards sample what's behind them every frame the background changes. The studio uses this on every iOS app it ships.

filterblur(22px) saturate(180%) layers3 animation22s drift
Today
72°
Clear skies over KMSP. Winds 280° at 8 knots.
Open invoices
$8.4K
3 outstanding. Oldest is 14 days, no follow-up sent.
Recovery
88
Sleep above baseline, HRV trending up the last 6 days.

How it works

backdrop-filter tells the compositor: before drawing this element, take what's behind it and apply this filter graph. We chain blur(22px) for the frosted-glass softness, then saturate(180%) to keep colors lively after the blur desaturates them.

The trick to feeling like liquid (not just frosted) glass is motion behind the surface. A conic-gradient rotates the color wheel from a single point; a second radial-gradient drifts on a 22s loop. As they move, the blur recomputes — and the glass appears to flow.

Each card has three contributing layers: the backdrop blur, an inner 1px highlight via inset box-shadow, and a soft drop shadow underneath for liftoff.

Trade-offs

  • One CSS property does the work — no overlays or duplicates
  • Updates automatically when content changes behind it
  • Used in Apple's Liquid Glass aesthetic shipped iOS 26
  • Backdrop-filter is expensive — keep glass area modest
  • Safari needs the -webkit- prefix; Firefox needs a feature flag pre-103

The essential code

glass.csscss
.glass-card {
  background: rgba(255,255,255,.18);
  backdrop-filter:        blur(22px) saturate(180%);
  -webkit-backdrop-filter: blur(22px) saturate(180%);
  border: 1px solid rgba(255,255,255,.42);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.60),  /* top highlight */
    0 22px 50px rgba(0,0,0,.18);             /* lift */
}
.stage {
  background: conic-gradient(from 0deg at 30% 40%,
    #7c5cff, #3a8fff, #40e6d9, #b6ff5c, #ff4d8d, #7c5cff);
}
@keyframes drift {
  0%   { transform: translate3d(-3%, -2%, 0) rotate(0deg); }
  100% { transform: translate3d(-2%,  3%, 0) rotate(-6deg); }
}

Specs

Bytes (gz)
~520 (CSS only)
Paint cost
~3–8ms per card ~
GPU accel
Yes (with compositor)
Browser support
96% global
JS required
None
No. 03Magnetic interaction · distance falloff

Buttons that reach for you.

Move your cursor near (not on) any button. The button moves toward you, the label inside moves at a different rate, and the radius pill shows the influence field. A tiny detail with a measurable effect on click-through.

radius110px strength0.32 label drift0.42× cursor

How it works

On every pointermove within the stage, we compute the Euclidean distance from the cursor to the button's center. If the distance is under the radius (110px), we translate the button toward the cursor — scaled by a strength (0.32) and a linear falloff that goes to zero at the edge.

The text inside the button uses a smaller multiplier (0.42 × parent offset). The label drifts past the button slightly — a parallax detail that suggests the button has weight and the text floats above it.

On pointerleave, an elastic cubic-bezier(.2,.7,.2,1) snaps everything home.

Trade-offs

  • Adds a clickable hit-target larger than the visible button
  • Increases perceived responsiveness — measurable in usability tests
  • Can mis-read on small targets — gate by min button size (≥44px)
  • Disabled for prefers-reduced-motion

The essential code

magnet.jsjavascript
const RADIUS = 110, STRENGTH = 0.32;

stage.addEventListener('pointermove', e => {
  const r  = btn.getBoundingClientRect();
  const dx = e.clientX - (r.left + r.width  / 2);
  const dy = e.clientY - (r.top  + r.height / 2);
  const d  = Math.hypot(dx, dy);

  if (d < RADIUS) {
    const falloff = (1 - d / RADIUS) * STRENGTH;
    btn.style.setProperty('--mx', dx * falloff + 'px');
    btn.style.setProperty('--my', dy * falloff + 'px');
  }
});

Specs

Bytes (gz)
~280 per button family
Frame budget
< 0.3ms (one hypot)
Easing
cubic-bezier(.2,.7,.2,1)
Reduced motion
Disabled
No. 04SVG filter morph · feGaussianBlur → feColorMatrix

Liquid that merges.

Four colored blobs orbit on independent CSS keyframes. An SVG filter chain blurs them, then snaps the alpha channel back to crisp — fusing any two blobs that get close. Zero canvas, zero WebGL, real-time GPU work.

blobs4 blur σ14px α gain22, offset −10 fps

How it works

The classic "goo effect" is a two-step SVG filter graph:

Step 1 — feGaussianBlur: each colored shape gets a heavy blur (σ = 14px). At their edges, alpha smoothly drops from 1.0 to 0.0. Where two blurred shapes overlap, the alpha values combine and never quite reach zero between them.

Step 2 — feColorMatrix: a 4×5 matrix that leaves RGB alone but transforms alpha as α' = 22·α − 10. Anything above ~0.45 alpha snaps to ~1.0 (and clamps); everything below snaps to 0. The blurred fuzzy edges become hard again, but the overlap region — where alpha was already >0.5 from two combined shapes — survives as one continuous blob.

No JavaScript runs during the animation. CSS keyframes orbit the blobs; the filter is recomputed by the GPU every frame.

Trade-offs

  • Pure declarative — zero canvas, zero WebGL, zero JS
  • Compositor-friendly on Chromium/Edge
  • Tunable: change σ for stickiness, change matrix gain for sharpness
  • Safari runs SVG filters on the CPU — costly on retina screens
  • Cannot interact with the blobs individually after fusing

The essential code

goo.svgsvg
<defs>
  <filter id="goo">
    <feGaussianBlur in="SourceGraphic" stdDeviation="14" />
    <!-- Identity for RGB; alpha = 22·α − 10 (snap edge) -->
    <feColorMatrix values="1 0 0 0 0
                            0 1 0 0 0
                            0 0 1 0 0
                            0 0 0 22 -10" />
  </filter>
</defs>
goo.csscss
.goo-canvas { filter: url(#goo); }   /* applies to all children */
.b1 { animation: goo1 14s ease-in-out infinite; }
@keyframes goo1 {
  0%,100% { transform: translate(20%, 30%); }
  50%     { transform: translate(60%, 60%); }
}

Specs

Bytes (gz)
~410 (SVG + CSS)
Frame cost
Chromium 1–2ms · Safari 4–8ms ~
GPU accel
Chromium yes · Safari CPU ~
Browser support
99% (SVG filters universal)
No. 05Scroll-driven SVG path · getPointAtLength

A flight from KMSP → KSFO, drawn as you scroll.

The path strokes itself in proportion to scroll. The aircraft sits exactly on the path and rotates to its tangent — the heading is computed from two adjacent samples on the curve. Turn a feature list into a journey.

scroll0% distance0 / 0 heading000° listenerrAF-throttled
KMSP Minneapolis KSFO San Francisco

How it works

Three independent things ride on the same scroll progress (a value in [0, 1]):

The path stroke: stroke-dasharray = total length, stroke-dashoffset = (1 − progress) × length. As progress increases, the dash retreats and the path appears to draw.

The plane position: path.getPointAtLength(progress × length) returns the exact (x, y) for that point on the curve. A transform translates the plane group there.

The heading: sample a second point 1px ahead, compute atan2(Δy, Δx), rotate the plane to that angle. The math is one line.

Scroll events fire 60+ times per second. We throttle through requestAnimationFrame so we only touch the DOM once per repaint — keeping a 60fps scroll even on low-end devices.

Trade-offs

  • Native SVG — no animation library, no canvas resize handling
  • Works on any path, including ones drawn in Illustrator
  • Scales perfectly — vector rendering at any DPR
  • getPointAtLength is O(n) — fine for paths under ~10K points
  • Browsers without the Scroll-Driven Animations API need this JS shim

The essential code

flightpath.jsjavascript
const total = path.getTotalLength();
path.style.strokeDasharray  = total;
path.style.strokeDashoffset = total;

function updatePlane() {
  const r = stage.getBoundingClientRect();
  const p = clamp((innerHeight - r.bottom) / (innerHeight + r.height), 0, 1);

  const len = p * total;
  path.style.strokeDashoffset = total - len;

  const pt  = path.getPointAtLength(len);
  const pt2 = path.getPointAtLength(len + 1);
  const ang = Math.atan2(pt2.y - pt.y, pt2.x - pt.x) * 180 / Math.PI + 90;

  plane.setAttribute('transform',
    `translate(${pt.x},${pt.y}) rotate(${ang})`);
}

// rAF-throttle so scroll events don't pile up
addEventListener('scroll', () => {
  if (!ticking) { requestAnimationFrame(() => { updatePlane(); ticking = false; }); ticking = true; }
}, { passive: true });

Specs

Bytes (gz)
~720 (JS + SVG)
Frame budget
~0.5ms per scroll
Throttling
requestAnimationFrame
DOM writes/frame
2 (dashoffset + transform)
No. 06Parallax depth · preserve-3d transform graph

Cards that have weight.

Hover any card. The card rotates around the axes your cursor commands; inside, the badge, the headline, and the shine all sit at different Z depths and move with parallax. The hover panel updates with the live rotation in degrees.

max tilt±12° lift30px Z last rx,ry0°, 0°
Now flying
Weather.Co
Shipped
Dispach
Coming next
tmpo

How it works

The parent grid declares a perspective: 1200px — every descendant shares the same vanishing point, so cards tilt consistently relative to a single virtual camera.

Each card has transform-style: preserve-3d, then a transform built from custom properties: rotateX(--rx) rotateY(--ry) translateZ(--tz). We compute rx and ry from cursor position normalized to [-1, 1] across the card, then multiplied by the max tilt (12°).

Inside the card, every child has its own translateZ — the badge at 50px, the headline at 85px, the shine at 60px. Because the parent has preserve-3d, those Z values stack in real space. As the card tilts, each layer moves with parallax — exactly the effect Apple uses on their product hero cards.

Trade-offs

  • Pure transform — GPU compositor only, no layout invalidation
  • Z values become art-directable depth slots
  • Stacks beautifully with shadows and shine layers
  • Beware overflow: hidden — clips the 3D space if too small
  • Disabled for reduced-motion (vestibular safety)

The essential code

tilt.csscss
.stage  { perspective: 1200px; }
.card {
  transform-style: preserve-3d;
  transform: rotateX(var(--rx)) rotateY(var(--ry)) translateZ(var(--tz));
}
.card .badge  { transform: translateZ(50px); }
.card .big    { transform: translateZ(85px); }
.card .shine  { transform: translateZ(60px); }
tilt.jsjavascript
card.addEventListener('pointermove', e => {
  const r = card.getBoundingClientRect();
  const x = (e.clientX - r.left) / r.width;   // 0..1
  const y = (e.clientY - r.top)  / r.height;
  card.style.setProperty('--ry', ((x - .5) * 24) + 'deg');
  card.style.setProperty('--rx', ((.5 - y) * 24) + 'deg');
  card.style.setProperty('--tz', '30px');
});

Specs

Bytes (gz)
~390 per card family
Layer count
3 per card (badge / title / shine)
Reduced motion
Disabled
Browser support
97% (preserve-3d)
No. 07Live data · Cloudflare Worker proxy · 60s edge TTL

Eight airports, updating live.

These cells call /api/public/metar?icao=KMSP on this site's Cloudflare Worker, which proxies aviationweather.gov with a 60s edge cache. Each cell pulses on its real flight category — VFR, MVFR, IFR, LIFR. The panel shows the live cache age and total round-trip latency.

endpoint/api/public/metar edge cache60s fastest—ms slowest—ms live0 / 8
KMSP
Minneapolis–St Paul
— · loading
KSFO
San Francisco
— · loading
KJFK
New York · JFK
— · loading
KORD
Chicago · O'Hare
— · loading
KDEN
Denver
— · loading
KATL
Atlanta
— · loading
KSEA
Seattle
— · loading
KBOS
Boston
— · loading

How it works

Three pieces fit together:

The browser fires 8 parallel fetch()s — one per ICAO — directly to kuhlman-co.com/api/public/metar. Same-origin, so no preflight, no CORS round-trip.

The Cloudflare Worker validates the ICAO (regex /^[A-Z]{4}$/ — anything else gets a 400), then issues an upstream fetch() to aviationweather.gov with cf: { cacheTtl: 60, cacheEverything: true }. That tells Cloudflare's edge to cache the upstream JSON for 60 seconds in the colo nearest the requester.

Subsequent requests for the same ICAO within 60s never leave the edge. Cold fetch: ~250ms. Warm cache: 8–25ms — about 30× faster.

The response is a normalized shape — wind, visibility, flight category, altimeter — about 300 bytes. The browser paints the cell, applies the flight-category class, and the pulse starts.

Trade-offs

  • Edge cache means upstream takes ~1% of our traffic at scale
  • Same-origin fetch — no CORS preflight tax
  • ICAO validation closes the proxy down to legit airport queries
  • The worker file: ~50 added lines, gzipped tiny
  • If aviationweather.gov goes down, our cache empties in ≤60s

The essential code

worker.js · handlePublicMetarjavascript
async function handlePublicMetar(request, url) {
  const icao = (url.searchParams.get('icao') || '').toUpperCase();
  if (!/^[A-Z]{4}$/.test(icao))
    return json({ error: '4-letter ICAO required' }, 400);

  const upstream = `https://aviationweather.gov/api/data/metar?ids=${icao}&format=json`;
  const res = await fetch(upstream, {
    cf: { cacheTtl: 60, cacheEverything: true },   // edge cache
  });
  const [m] = await res.json();

  return json({
    icao: m.icaoId, observed: m.reportTime,
    flight_category: m.fltCat,
    temp_c: m.temp, wind_dir: m.wdir, wind_speed: m.wspd,
    visibility: m.visib,
    altim_in_hg: +(m.altim * 0.02953).toFixed(2),
  }, 200, { 'Cache-Control': 'public, max-age=60' });
}

Specs

Endpoint
/api/public/metar?icao=…
Edge TTL
60s (cf.cacheTtl)
Response size
~300 B (normalized JSON)
Cold fetch
~250ms (upstream)
Warm cache
8–25ms
Worker code
~50 lines, single handler
No. 08Particle physics · canvas2d

Click for delight.

Tap the button. Eighty short-lived particles spawn into a single canvas, each with a random velocity vector. Gravity pulls them down, air drag slows them, they spin on their own axis, and they fade out. The whole thing runs in under 4ms a frame.

spawned0 alive0 peak0 frame0ms
click again. and again.

How it works

Three things, all on one canvas:

Spawn: each click pushes 80 particles into a shared array. Each gets a random angle in the upper half-circle, a random initial speed (8–18 px/frame), a color from a fixed palette, a random size, and a random rotation rate.

Step: every frame we apply gravity (vy += 0.32), air drag (v *= 0.985), and rotation. A particle dies when it falls below the canvas or its lifetime hits zero.

Draw: rotated rectangles with fading alpha. A single canvas2d context handles all of it — no per-particle DOM, no per-particle event listeners.

The frame budget readout shows the actual time spent in the simulation step + draw, averaged over the last 30 frames. Burst it ten times — you'll see frame time stay near 1ms.

Trade-offs

  • One canvas, one shared particle array — no GC churn during burst
  • Drag + gravity feel right at (0.985, 0.32) — empirically tuned
  • Dies when alpha hits zero, freeing the array slot
  • Canvas 2D tops out around 5K particles at 60fps — pool + WebGL beyond that
  • Resize handler must keep DPR alignment, or rectangles get fuzzy

The essential code

confetti.jsjavascript
const g = 0.32, drag = 0.985;
const palette = ['#3a8fff','#40e6d9','#ff4d8d','#b6ff5c','#ffdc73'];

function burst(x, y) {
  for (let i = 0; i < 80; i++) {
    const a = Math.random() * Math.PI - Math.PI;        // upper half-circle
    const v = 8 + Math.random() * 10;
    particles.push({
      x, y,
      vx: Math.cos(a) * v, vy: Math.sin(a) * v,
      rot: 0, drot: (Math.random() - .5) * .4,
      size: 6 + Math.random() * 8,
      color: palette[i % palette.length],
      life: 1,
    });
  }
}

function step() {
  for (const p of particles) {
    p.vx *= drag; p.vy = p.vy * drag + g;
    p.x  += p.vx; p.y += p.vy; p.rot += p.drot;
    p.life -= 0.012;
  }
}

Specs

Per burst
80 particles
Gravity
0.32 px/frame²
Drag
0.985 multiplier
Frame cost
~1ms (no GC)
API
canvas2d (universal)
No. 09Scroll-reactive motion · velocity smoothing

A ticker that reacts to your scroll.

Scroll down — the marquee speeds up and pulls in the same direction. Scroll up — it slows, stops, reverses. When you hold still, it returns to its baseline drift. The same pattern shows up on Stripe, Vercel, and Apple's product pages.

scroll v0 px/f marquee v2.0 px/f direction listenerrAF only
Motion Depth Interaction Liquid Glass Live data Shipped
// hand-built ·  // no frameworks ·  // 38 KB page ·  // shipped from KMSP ·  // est 2025 · 
Aviation Finance Health Productivity Games Music

How it works

Each row has a baseline velocity (in pixels per frame). A scroll listener captures scrollY deltas and feeds them through an exponential moving average — that's the smoothed "scroll velocity."

Every animation frame, the row's effective velocity becomes base + scrollVel × 0.3. Sign flips automatically when the influence outweighs the base. The transform is updated as translateX(currentX % rowWidth) — the modulus keeps it seamless because each row contains exactly twice the visible width of content.

The smoothing factor matters. Too snappy and the marquee jitters with every scroll micro-event; too sluggish and it lags behind the user. 0.85 on the EMA + 0.3 influence is the sweet spot.

Trade-offs

  • One rAF loop drives all rows — O(rows) per frame
  • Scroll handler is passive — never blocks scroll
  • Pure transform — composited, no layout
  • Content must be exactly 2× the viewport — duplicate the loop in HTML or in JS
  • Page resize requires recomputing row width

The essential code

marquee.jsjavascript
let lastY = scrollY, scrollVel = 0;

addEventListener('scroll', () => {
  const delta = scrollY - lastY;
  lastY = scrollY;
  scrollVel = scrollVel * 0.85 + delta * 0.15;   // EMA smooth
}, { passive: true });

function tick() {
  scrollVel *= 0.92;                                   // decay back to 0

  rows.forEach(row => {
    const base = +row.dataset.base;
    row._x = (row._x || 0) - (base + scrollVel * 0.3);
    const w = row.scrollWidth / 2;        // content is 2× duplicated
    const x = ((row._x % w) + w) % w;  // always-positive modulus
    row.style.transform = `translateX(${-x}px)`;
  });
  requestAnimationFrame(tick);
}
tick();

Specs

EMA factor
0.85 / 0.15
Scroll influence
0.30
Decay per frame
0.92×
DOM writes/frame
1 per row (transform)
Scroll handler
passive
No. 10Generative art · canvas + pseudo-noise vector field

Wind, made of particles.

An invisible vector field fills the panel — at each point in space, a heading is computed from a noise function. Six hundred particles drift through it, leaving fading trails. Nearby particles move in similar directions because the field is smooth, so the whole thing reads as wind aloft.

particles0 fps noise scale0.0035 flow t0.00
WIND ALOFT · simulated vector field

How it works

The trick to making generative motion feel organic instead of random is a smooth vector field. We use a cheap pseudo-noise:

angle(x, y, t) = (sin(x · 0.0035 + t) + cos(y · 0.0035 + t · 0.7)) × π

Plug in any (x, y) and you get an angle in radians. Nearby points get similar angles — that's what makes it look like a flow. The t term slowly evolves the field over time, so even a stationary particle would slowly arc.

Each frame: for every particle, look up its local angle, add a velocity vector in that direction (small, ~1 px), draw a 1px line from old position to new. Particles that drift off the canvas respawn in random positions.

Trails come for free: instead of clearing the canvas each frame, we paint a rgba(10, 10, 20, 0.06) rectangle over it. Old pixels fade slowly; the live lines stay bright.

Trade-offs

  • Pure canvas2d — no shaders, no libraries
  • Trail-via-fade rect is 1 fillRect per frame instead of 600 paths
  • Pseudo-noise is fast (2 sin + 1 cos per lookup)
  • Trail color must match the bg, or you get a haze of the wrong tint
  • For real Perlin/simplex noise, swap in a 600-byte implementation

The essential code

flowfield.jsjavascript
const N = 600, K = 0.0035;
let t = 0;

function field(x, y) {     // returns angle in radians
  return (Math.sin(x * K + t) + Math.cos(y * K + t * 0.7)) * Math.PI;
}

function frame() {
  ctx.fillStyle = 'rgba(10,10,20,0.06)';     // fade old pixels
  ctx.fillRect(0, 0, w, h);
  ctx.strokeStyle = 'rgba(120,200,255,0.5)';
  ctx.beginPath();
  for (const p of ps) {
    const a = field(p.x, p.y);
    const nx = p.x + Math.cos(a);
    const ny = p.y + Math.sin(a);
    ctx.moveTo(p.x, p.y); ctx.lineTo(nx, ny);
    p.x = nx; p.y = ny;
    if (p.x < 0 || p.x > w || p.y < 0 || p.y > h) respawn(p);
  }
  ctx.stroke();
  t += 0.002;
  requestAnimationFrame(frame);
}

Specs

Particles
600
Noise scale
0.0035 (gentle swirl)
Trail fade α
0.06 per frame
Per-frame paths
1 (batched lineTo)
Frame cost
2–4ms
No. 11Solari board · per-character cascade animation

A board that flips.

The classic Solari mechanical departures board — recreated in plain text. Every few seconds a flight updates: each character cell flips through a random alphabet at 50ms intervals, settling on the target with a small cascade delay so columns lock left-to-right. No animation library, no canvas.

rows6 flips0 last update alphabet40 chars
Flight
Destination
Time
Gate
Status

How it works

Each cell is a normal <div> with text content. To flip to a new value, JavaScript spawns a tiny per-cell loop:

Step 1: every 50ms, set the cell's text to a random character from the alphabet (uppercase + digits + a few symbols). The cell briefly blinks via a CSS animation to suggest the mechanical flap.

Step 2: after a per-column "settle delay" (the column index × 80ms), the cell's loop is interrupted and the real target character is locked in. The result: columns finish in order, left to right — that's what makes it look like a Solari board cascading.

Every 3.5 seconds we pick a random row and overwrite all five of its cells with a new flight. The amber color, the per-row scanlines, and the inner box-shadow all sell the "old screen on the airport wall" effect.

Trade-offs

  • Zero animation libraries — pure text substitution
  • Screen-reader friendly — the final text is real DOM text
  • Tunable: alphabet, flip rate, cascade delay, update interval
  • setInterval per cell scales linearly — for 100+ rows, batch into a single timer
  • Color and shadows do the heavy lifting; without them the cells feel "fake"

The essential code

splitflap.jsjavascript
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-↑→★· ';

function flipCell(cell, target, settleDelay) {
  const end = performance.now() + settleDelay;
  const id = setInterval(() => {
    if (performance.now() >= end) {
      cell.textContent = target;
      clearInterval(id);
      return;
    }
    cell.textContent = ALPHABET[Math.random() * ALPHABET.length | 0];
    cell.classList.add('flip');
    setTimeout(() => cell.classList.remove('flip'), 60);
  }, 50);
}

function updateRow(row, flight) {
  flight.forEach((value, col) => {
    flipCell(row.children[col], value, 700 + col * 220);     // cascade
  });
}

Specs

Alphabet
40 chars (A-Z, 0-9, glyphs)
Flip rate
50ms per character
Settle delay
700ms + col × 220ms
Update interval
3.5s per row
Color
#ffdc73 (Solari amber)
No. 12WebGL fragment shader · the GPU at full work

A gradient mesh, computed pixel-by-pixel on the GPU.

Everything you've seen so far has been the compositor pushing pixels around. This is something different — every pixel of this panel is computed from scratch by a fragment shader, every frame, on the GPU. ~120 lines including the GLSL source.

api resolution fps uniform t0.00
FRAGMENT SHADER · 28 LINES OF GLSL

How it works

Three pieces. First, a vertex shader that does almost nothing — just two triangles covering the whole canvas (a full-screen quad). Its only job is to give the rasterizer a surface to fill.

Second, a fragment shader — that's the interesting one. It runs once per pixel, every frame, in parallel on hundreds of GPU cores. For each pixel it computes a UV coordinate, blends three colored radial "blobs" (each drifting on its own sine wave), and returns the final color. The blobs read positions from a uniform time that we update every frame from JavaScript.

Third, the JS plumbing: compile the shaders, link the program, set up a vertex buffer for the quad, and on every requestAnimationFrame push the new time uniform and call drawArrays. That's the whole drawing call — the GPU does the rest.

If WebGL isn't available, we paint a static CSS fallback so the panel never goes blank.

Trade-offs

  • Massively parallel — each pixel is independent, GPU eats this for breakfast
  • Frame cost barely scales with canvas size — the GPU has the cores
  • Same shader can run at 4K with no JS changes
  • Hits 60fps even on integrated Intel graphics
  • WebGL setup is verbose — getContext, attribs, uniforms, buffers
  • Shader bugs are silent until you log program info

The essential code

mesh.fragglsl
precision mediump float;
uniform float  u_time;
uniform vec2   u_res;

vec3 blob(vec2 uv, vec2 c, vec3 col, float r) {
  float d = length(uv - c);
  float f = smoothstep(r, 0.0, d);
  return col * f;
}
void main() {
  vec2 uv = gl_FragCoord.xy / u_res;
  uv.x *= u_res.x / u_res.y;
  float t = u_time;

  vec3 col = vec3(0.02, 0.02, 0.05);
  col += blob(uv, vec2(0.6 + sin(t*.4)*.3, 0.4 + cos(t*.3)*.3), vec3(.23,.56,1.0), .6);
  col += blob(uv, vec2(0.3 + cos(t*.5)*.3, 0.7 + sin(t*.6)*.2), vec3(.49,.36,1.0), .55);
  col += blob(uv, vec2(0.9 + sin(t*.7)*.2, 0.8 + cos(t*.4)*.2), vec3(1.0,.30,.55), .5);

  // subtle grain — anti-banding
  col += (fract(sin(dot(gl_FragCoord.xy, vec2(12.9,78.2))) * 43758.5) - .5) * .012;
  gl_FragColor = vec4(col, 1.0);
}

Specs

API
WebGL 1.0 (universal)
Shader
~28 lines GLSL
Per-frame draw call
1 (drawArrays · 6 verts)
Uniforms updated
1 per frame (u_time)
Fallback
CSS radial gradients
No. 13Interactive physics · critically-damped spring

Grab it. Fling it. Watch it spring home.

Drag the card anywhere in the panel. Let go — it returns to center under a critically-damped spring. The faster you toss it, the harder it overshoots. Numerical integration runs at 60Hz with F = −kx − cv; the tunings (k = 0.10, c = 0.18) are right at the edge of bounce.

stiffness k0.10 damping c0.18 offset0, 0 px velocity0.0 px/f stateidle
Specimen 13
drag · throw · watch

How it works

The card has a position (x, y) and a velocity (vx, vy). Two modes:

Dragging: pointer down captures the cursor offset; pointer move updates position directly. We sample velocity from the last few moves so we know how hard you're throwing.

Springing: pointer up hands control to the physics loop. Every frame: compute spring force toward origin F = −k × x, plus damping −c × v. Add force to velocity (acceleration), add velocity to position. Stop when both displacement and velocity are smaller than a threshold.

The tunings are at the edge of critical damping. With k = 0.10 and c = 0.18, the card pulls toward origin firmly but only overshoots if you flung it hard. Lower c = more bounce; higher c = sluggish return.

Trade-offs

  • One animation loop, two state machines (drag vs spring)
  • Velocity sampled from history — robust to skipped frames
  • Tunable in two numbers — easy to dial in by feel
  • Pointer capture (setPointerCapture) is essential for fast drags off the element
  • Touch needs touch-action: none on the stage to prevent scroll

The essential code

spring.jsjavascript
const k = 0.10, c = 0.18;       // stiffness, damping
let x = 0, y = 0, vx = 0, vy = 0;
let dragging = false;

card.addEventListener('pointerdown', e => {
  card.setPointerCapture(e.pointerId);
  dragging = true;
});
card.addEventListener('pointermove', e => {
  if (!dragging) return;
  const nx = x + e.movementX, ny = y + e.movementY;
  vx = nx - x; vy = ny - y;        // sample velocity
  x = nx; y = ny;
});
card.addEventListener('pointerup', () => dragging = false);

function step() {
  if (!dragging) {
    // F = -kx - cv  → integrate (Euler is fine at 60Hz)
    vx += -k * x - c * vx;
    vy += -k * y - c * vy;
    x += vx; y += vy;
  }
  card.style.setProperty('--x', x + 'px');
  card.style.setProperty('--y', y + 'px');
  requestAnimationFrame(step);
}
step();

Specs

Stiffness k
0.10 (firm pull)
Damping c
0.18 (slight bounce)
Integration
Semi-implicit Euler
Pointer capture
setPointerCapture
Touch
touch-action: none
No. 14View Transitions API · single-DOM crossfade

Two states. One cinematic crossfade.

Click any thumbnail. The browser snapshots the old hero, mutates the DOM to the new hero, snapshots the new one, and crossfades between them — all from a single line of JavaScript. No animation library, no FLIP gymnastics. Modern Chromium and Safari only.

api support transitions0 last duration implementationdocument.startViewTransition
Hero · Cobalt

How it works

The View Transitions API exists in one line:

document.startViewTransition(() => mutateTheDOM())

That's it. The browser takes a snapshot of the page before your callback runs, then runs the callback (which can mutate any DOM, anywhere), then takes a snapshot after. It crossfades between the two snapshots — using view-transition-name CSS to identify elements that should morph between their old and new positions rather than fade.

Here, the hero has view-transition-name: vt-hero. When we change its background and label, the browser sees the old hero and new hero share a name — so instead of fading, it interpolates between them: position, size, opacity, all together.

Without the API (older browsers), we fall back to a 200ms opacity transition. Same visual goal, just no morph.

Trade-offs

  • Single-line API — no animation library, no FLIP code
  • Browser handles snapshots; you handle the DOM change
  • Works for app-level transitions (page navigations too, via cross-document VT)
  • Chromium 111+, Safari 18+ — Firefox still behind flag
  • Skip-on-click required if user clicks again mid-transition

The essential code

viewtransitions.jsjavascript
function switchHero(newGradient, newLabel) {
  const mutate = () => {
    hero.style.background = newGradient;
    label.textContent = newLabel;
  };

  if (document.startViewTransition) {
    const tx = document.startViewTransition(mutate);
    tx.finished.then(() => log('transition complete'));
  } else {
    hero.style.opacity = 0;
    setTimeout(() => { mutate(); hero.style.opacity = 1; }, 200);
  }
}
vt.csscss
.hero {
  view-transition-name: vt-hero;     /* names the morphed element */
}
::view-transition-old(vt-hero),
::view-transition-new(vt-hero) {
  animation-duration: 400ms;          /* tune via pseudo-element */
}

Specs

API
document.startViewTransition
Browser support
Chromium 111+, Safari 18+ ~
Fallback
opacity transition (200ms)
Code shipped
~25 lines including fallback
No. 15Custom cursor · context-aware morph

A cursor that knows what it's over.

Hover anywhere in the panel — the system cursor disappears, replaced by a ring with a precision dot inside. Hover a link or button and the ring inflates and fills. Hover text and it becomes a thin I-beam. Everything sits behind mix-blend-mode: difference so it inverts whatever it crosses.

Empty space

The default state. Ring + dot, both inverted via blend-mode.

Hover me — link state

Link state

The ring inflates from 44px → 80px and fills with a translucent white.

Text state

Hover this paragraph — the ring collapses into a thin I-beam, indicating selection is possible.

Another link
stateidle x, y0, 0 blenddifference

How it works

Two absolutely-positioned elements ride the cursor: a small dot (precision indicator) and a larger ring (the visible "cursor"). On every pointermove, we update their translate positions to follow.

State changes come from pointerover: when the cursor enters a button or link, we add .is-link to the ring — CSS handles the inflation animation. When it enters a paragraph tagged data-cursor-text, we add .is-text — the ring collapses into a 4×28px vertical bar.

The trick that ties it all together: both elements use mix-blend-mode: difference. That means they invert whatever color is behind them. Over the dark panel they appear cyan/white; over a bright element they invert back. One cursor design, perfect contrast everywhere.

The native cursor is hidden via cursor: none on the stage. On touch devices we restore it — there's no cursor to replace.

Trade-offs

  • Two DOM elements, one transform each per pointermove — trivial cost
  • State driven by CSS classes — animations free
  • mix-blend-mode handles contrast across any background
  • Native cursor accessibility hints lost — keep it scoped to one area
  • Touch devices need the native cursor restored

The essential code

cursor.jsjavascript
stage.addEventListener('pointermove', e => {
  const r = stage.getBoundingClientRect();
  const x = e.clientX - r.left, y = e.clientY - r.top;
  dot.style.transform  = `translate(${x}px, ${y}px) translate(-50%, -50%)`;
  ring.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`;
});

stage.addEventListener('pointerover', e => {
  ring.classList.remove('is-link', 'is-text');
  if (e.target.closest('a, button'))      ring.classList.add('is-link');
  else if (e.target.closest('[data-cursor-text]')) ring.classList.add('is-text');
});
cursor.csscss
.stage { cursor: none; }                          /* hide native */
.ring  { mix-blend-mode: difference; }              /* invert whatever's behind */
.ring.is-link { width: 80px; height: 80px; background: rgba(255,255,255,.16); }
.ring.is-text { width: 4px;  height: 28px; border-radius: 2px; }

Specs

DOM elements
2 (dot + ring)
States
idle · link · text
Contrast trick
mix-blend-mode: difference
Touch
Falls back to native cursor
No. 16Live data · ADS-B aircraft over KMSP · 10s edge cache

Every plane in the sky over Minneapolis, right now.

The map shows live aircraft within 200nm of KMSP, sourced from ADS-B transponders. The Cloudflare Worker proxies adsb.lol, edge-caches the response 10 seconds, and projects lat/lng into a simple equirectangular map centered on the airport. The list at right shows callsign, altitude, ground speed, and heading.

N S W E 50nm 100nm 150nm 200nm KMSP

Aircraft in range connecting…

count latency refresh10s
CallsignAlt · SpdHdg

How it works

Three pieces — same pattern as the METAR specimen, with two extra wrinkles:

The Cloudflare Worker hits api.adsb.lol/v2/point/{lat}/{lon}/{dist} for aircraft within 200nm of KMSP (44.88, −93.22). Edge cache: 10s — short enough to feel live, long enough that 99% of traffic never reaches the upstream. The response gets normalized down to ~150 bytes per aircraft: callsign, lat, lon, altitude, ground speed, track.

The projection: this map covers ±200nm — small enough that equirectangular projection is accurate to about 1%. Convert each aircraft's lat/lng to nautical-mile offsets from KMSP, then to the SVG viewBox (200, 200) center. One minute of latitude = 1nm.

The render: each aircraft becomes a small triangle rotated to its track. We diff old vs new on each refresh — planes that disappear fade out, new arrivals are flagged in the side list with a brief amber highlight. The list refreshes every 10s.

Trade-offs

  • Edge cache absorbs 99% of traffic — single-digit RPS to upstream
  • Equirectangular is fine for small areas (under ~500nm radius)
  • SVG triangles + transforms — vector-perfect at any zoom
  • Upstream validates ICAO bounds and rate-limits — proxy keeps it polite
  • Out of range = empty list (good behavior, not a bug)
  • Upstream sometimes flickers — handled with a "stale-while-revalidate" feel

The essential code

worker.js · handlePublicAircraftjavascript
async function handlePublicAircraft(_, url) {
  const lat = clamp(parseFloat(url.searchParams.get('lat') || 44.88), -90, 90);
  const lon = clamp(parseFloat(url.searchParams.get('lon') || -93.22), -180, 180);
  const dist = clamp(parseFloat(url.searchParams.get('dist') || 200), 5, 250);

  const upstream = `https://api.adsb.lol/v2/point/${lat}/${lon}/${dist}`;
  const res = await fetch(upstream, {
    cf: { cacheTtl: 10, cacheEverything: true },
  });
  const { ac = [] } = await res.json();

  return json({
    count: ac.length,
    center: { lat, lon }, radius_nm: dist,
    aircraft: ac.slice(0, 60).map(a => ({
      hex: a.hex, flight: (a.flight || '').trim(),
      lat: a.lat, lon: a.lon,
      alt: a.alt_baro || a.alt_geom || null,
      gs: a.gs || null, track: a.track || a.true_heading || null,
    })),
  });
}

Specs

Endpoint
/api/public/aircraft
Edge TTL
10s (cf.cacheTtl)
Upstream
api.adsb.lol
Refresh
10s polling
Aircraft cap
60 per response
Per-aircraft size
~150 B normalized
No. 17Cellular automaton · classic CS demo

Four rules. Infinite emergence.

Conway's Game of Life. Each cell is alive or dead. Each generation, a cell with 2–3 live neighbours survives, a dead cell with exactly 3 neighbours is born, everyone else dies. From those four rules: gliders, oscillators, spaceships, and the Gosper gun that fires gliders forever. Click cells to seed, then watch.

grid— × — generation0 alive0 step— ms

How it works

Two grids the same size: a current generation and a next one. Each tick, for every cell, count its eight neighbours. Apply Conway's rules to compute the new value into next. When done, swap current and next. (Double-buffering — never mutate the grid you're reading from.)

The grid is a flat Uint8Array, not a 2D array — index is y × width + x. Faster memory access, no nested arrays to allocate per generation. Wrap edges with modulo for a toroidal world (top connects to bottom; left to right) — patterns can fly off one side and re-enter the other.

Rendering is one canvas fillRect per live cell. We cache the cell size and only redraw between steps, not every frame — there's nothing to animate inside a single generation.

Trade-offs

  • Flat Uint8Array — fastest data structure for fixed grids
  • Double-buffered — no read-during-write bugs
  • Toroidal wrap — patterns survive instead of dying at edges
  • Render decoupled from sim — can run sim N steps per render for speed
  • O(width × height) per generation — slows on enormous grids
  • For huge grids (10000×10000), use HashLife instead — algorithmic, not naive

The essential code

life.jsjavascript
function step(cur, next, W, H) {
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      let n = 0;
      for (let dy = -1; dy <= 1; dy++) {
        for (let dx = -1; dx <= 1; dx++) {
          if (dx === 0 && dy === 0) continue;
          // toroidal wrap with modulo
          const nx = (x + dx + W) % W;
          const ny = (y + dy + H) % H;
          n += cur[ny * W + nx];
        }
      }
      const alive = cur[y * W + x];
      // Conway's rules in one line:
      next[y * W + x] = (alive && (n === 2 || n === 3)) || (!alive && n === 3) ? 1 : 0;
    }
  }
}

Specs

Grid type
Uint8Array (flat)
Buffering
Double (current + next)
Wrap
Toroidal (mod W/H)
Tick rate
~12 generations/sec
Render
fillRect per live cell
No. 18Scroll-driven CSS · animation-timeline: view()

The same scroll-reveal — with zero JavaScript.

Specimen 05 used a scroll listener and getPointAtLength to drive its animation. This one does the same kind of reveal entirely through CSS — the new animation-timeline: view() ties keyframes to the element's position in the viewport. No listener, no JS, no rAF. The instrumentation here is just a feature detector — the demo itself is pure CSS.

apichecking… implementationanimation-timeline: view() JS lines0
↓ Scroll slowly — watch each card unfold as it enters the viewport
Specimen one
Specimen two
Specimen three
Specimen four
— end of scroll-driven section —

How it works

A normal @keyframes rule defines the from/to state — scale 0.85 + opacity 0.4 + blur 2px → scale 1 + opacity 1 + blur 0. A normal animation declaration applies it. The new piece is one extra line:

animation-timeline: view();

That tells the browser: don't tick this animation by wall-clock time. Tick it by the element's position relative to the viewport. The companion property animation-range: entry 0% cover 50% says: start the animation when the element first enters the viewport, finish when it has been covered halfway through.

Without the API (Firefox, older Safari) we fall back to an IntersectionObserver that adds .is-near when the element is in view. Same visual result, ~10 lines of JS.

Trade-offs

  • Zero JavaScript when supported — the browser does scroll math natively
  • Compositor-friendly — animations run off the main thread
  • Works with any keyframe property — transform, opacity, color, anything
  • One line per animated element instead of a scroll-watching script
  • Chromium 115+, Safari 26+ — Firefox still behind flag
  • No JS hook for the progress — pure visual; instrument via IntersectionObserver separately

The essential code

scrollreveal.csscss
@keyframes scrollReveal {
  from { transform: scale(.85) translateY(40px); opacity: .4; filter: blur(2px); }
  to   { transform: scale(1) translateY(0);    opacity: 1;  filter: blur(0); }
}

.item {
  animation: scrollReveal linear both;
  animation-timeline: view();              /* tie to scroll position */
  animation-range: entry 0% cover 50%;   /* start / finish points */
}

/* Graceful fallback for browsers without animation-timeline */
@supports not (animation-timeline: view()) {
  .item            { transition: transform .5s, opacity .5s, filter .5s; /* + initial state */ }
  .item.is-near    { transform: scale(1); opacity: 1; filter: blur(0); }
}

Specs

API
animation-timeline: view()
Browser support
Chromium 115+, Safari 26+ ~
JS shipped
0 lines (just feature detect)
Off-main-thread
Yes
Fallback
IntersectionObserver + class
No. 19Web Audio · procedural sound + live FFT

A drum machine, synthesized in the browser.

Click "Start audio" then tap any pad. Each pad triggers a short, procedurally generated sound — no audio files. An AnalyserNode taps the master bus and a canvas draws the FFT bars in real time. No microphone needed; no permission required beyond the click that starts the AudioContext.

contextsuspended sample rate— Hz fft bins128 peak0.00
FFT · 128 bins · 60Hz update

How it works

A single shared AudioContext sits behind a master GainNode and an AnalyserNode. Browsers require a user gesture (a click) before any sound — so the start button calls ctx.resume() on first press.

Each pad builds a tiny audio graph and plays it once. Kick: a sine oscillator whose frequency drops from 120Hz to 40Hz over 100ms, multiplied by a 200ms amplitude envelope. Snare: white noise (a 0.5s AudioBuffer filled with Math.random()) + a 180Hz triangle, both through a high-pass at 1kHz. Hi-hat: noise through a 7kHz high-pass with a 60ms decay. Chord: four sawtooth oscillators tuned to C–E–G–D (a Cmaj9), gentle attack, 1s release.

The visualizer is one getByteFrequencyData call per frame, then 128 fillRects on a canvas. The bars decay with a 0.95 multiplier so they fall smoothly rather than snapping.

Trade-offs

  • Zero audio assets — entirely procedural, ships at 0 bytes of media
  • Each sound is one disposable subgraph — garbage-collected after release
  • AnalyserNode is real-time and free; FFT runs in C++ inside the browser
  • First sound is silent until ctx.resume() — autoplay policy is strict
  • Sample-rate-dependent — at 48kHz vs 44.1kHz the FFT bins shift slightly

The essential code

synth.jsjavascript
function kick(ctx, dest) {
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.type = 'sine';
  osc.frequency.setValueAtTime(120, ctx.currentTime);
  osc.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.10);
  env.gain.setValueAtTime(0.9, ctx.currentTime);
  env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.20);
  osc.connect(env).connect(dest);
  osc.start();
  osc.stop(ctx.currentTime + 0.25);                // subgraph is GC'd after
}

function draw() {                                  // FFT visualizer
  analyser.getByteFrequencyData(bins);
  for (let i = 0; i < bins.length; i++) {
    const h = (bins[i] / 255) * canvas.height;
    ctx2d.fillRect(i * barW, canvas.height - h, barW - 1, h);
  }
  requestAnimationFrame(draw);
}

Specs

Audio assets
0 bytes (procedural)
Sample rate
device-native (44.1k / 48k)
FFT size
256 (128 bins)
Latency
~10–25ms (interactive)
Autoplay policy
Resume on user gesture
No. 20Drag-to-reorder · the FLIP technique

A list that rearranges itself smoothly.

Drag any row up or down. As you cross another row's midpoint, the two swap — and the displaced row slides into place via FLIP (First, Last, Invert, Play). The dragged card never animates; only its neighbours do. No drag-and-drop library, no animation library — about 80 lines of pointer handling and one CSS transition.

items6 swaps0 dragidle offset0 px
⋮⋮01Pre-flight: file IFR plan with Foreflight06:30
⋮⋮02Walk-around inspection at the FBO06:50
⋮⋮03Engine start · taxi to runway 30L07:05
⋮⋮04Run-up · mags check · takeoff brief07:15
⋮⋮05Climb to FL090 · engage autopilot07:25
⋮⋮06Cruise · coffee · enjoy the view07:45

How it works — the FLIP technique

The acronym from Paul Lewis: First, Last, Invert, Play. The trick to animating a DOM mutation smoothly:

First: before changing anything, record each affected element's getBoundingClientRect().
Last: make the DOM change (here, reorder the children in the parent).
Invert: read the new positions. For each element, compute the delta from first to last and apply an inverse transform — so the element visually appears in its old position.
Play: clear the transform on the next frame with a transition. The browser animates from inverted (= old position) to no transform (= new position).

Result: the user sees a smooth slide, but you actually did a DOM reorder. Layout is correct, accessibility is correct (tab order matches DOM order), and you only animate via cheap GPU transforms.

The dragged card uses setPointerCapture so a fast drag off the row doesn't lose focus, and touch-action: none so mobile users don't accidentally scroll the page.

Trade-offs

  • DOM order is the source of truth — accessibility, tab order, screen readers all correct
  • Animation is pure transform — GPU compositor, no layout cost
  • No drag-and-drop library — saves ~30KB
  • Two layout reads per swap (First + Last) — measure-first to batch
  • Don't animate the dragged element itself — it follows the pointer, not a transition

The essential code

flip.jsjavascript
function swap(a, b) {
  // FIRST — record positions before the DOM change
  const aFirst = a.getBoundingClientRect();
  const bFirst = b.getBoundingClientRect();

  // LAST — make the change (real DOM swap)
  const parent = a.parentElement;
  parent.insertBefore(b, a);

  // INVERT — read new positions, apply inverse transform
  const aLast = a.getBoundingClientRect();
  const bLast = b.getBoundingClientRect();
  a.style.transition = 'none';
  b.style.transition = 'none';
  a.style.transform = `translateY(${aFirst.top - aLast.top}px)`;
  b.style.transform = `translateY(${bFirst.top - bLast.top}px)`;

  // PLAY — next frame, drop the transform with a transition
  requestAnimationFrame(() => {
    a.style.transition = 'transform 320ms cubic-bezier(.2,.7,.2,1)';
    b.style.transition = 'transform 320ms cubic-bezier(.2,.7,.2,1)';
    a.style.transform = '';
    b.style.transform = '';
  });
}

Specs

Technique
FLIP (First-Last-Invert-Play)
Animation
CSS transform only
Layout reads
2 per swap (rect × 2)
Pointer capture
setPointerCapture
Touch
touch-action: none
No. 21EyeDropper API · sample any pixel on screen

Pick a color from anywhere on your screen.

Click the button. The browser hands the OS color picker to you — magnify any pixel anywhere on your display (including outside the browser window) and click to capture. The returned hex value paints the swatch and adds to your history. A web API that feels like a desktop app.

Sampled color
#3a8fff
rgb(58, 143, 255) · luminance 0.42
apichecking… picks0
Recent picks
Chromium & Edge 95+ · Safari & Firefox not yet

How it works

The entire API is six lines:

const eye = new EyeDropper();
const { sRGBHex } = await eye.open();

That's it. eye.open() returns a promise. The browser hands control to the operating system's color picker — a magnifier follows the cursor, and on click it returns the picked color as a hex string. The user can pick a color from any pixel on the screen, including outside the browser window — your terminal, a photo, another website.

We compute display info from the hex: convert to RGB, compute perceived luminance via 0.2126·R + 0.7152·G + 0.0722·B (the relative luminance formula from WCAG), and flip the swatch's text color from white to dark when needed for contrast.

The API requires a user gesture and a secure context (HTTPS). It's not supported in Safari or Firefox yet — we feature-detect and disable the button gracefully if missing.

Trade-offs

  • Six lines including the await — possibly the smallest "real" web API
  • OS-level picker means perfect cross-monitor accuracy
  • Privacy-respectful — only the picked color is returned, never the screen content
  • Chromium-only — Safari 18 and Firefox haven't shipped it
  • Requires HTTPS and a user gesture — won't work in dev over http://

The essential code

dropper.jsjavascript
btn.addEventListener('click', async () => {
  if (!window.EyeDropper) return showFallback();

  const eye = new EyeDropper();
  try {
    const { sRGBHex } = await eye.open();    // hex like "#a1b2c3"
    paintSwatch(sRGBHex);
    history.unshift(sRGBHex);
  } catch (e) {
    // User pressed Escape — no harm done
  }
});

function luminance(hex) {
  const n = parseInt(hex.slice(1), 16);
  const r = ((n >> 16) & 0xff) / 255;
  const g = ((n >> 8) & 0xff) / 255;
  const b = (n & 0xff) / 255;
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;   // WCAG formula
}

Specs

API
window.EyeDropper
Browser support
Chromium 95+ / Edge 95+ ~
Required context
HTTPS + user gesture
Returns
sRGB hex string
Privacy
Only the picked pixel, never the screen
No. 22Strange attractor · 3D math viz

A butterfly traced in chaos.

Lorenz's three-equation system from 1963 — a toy weather model that turned out to be the seed of chaos theory. We integrate it 60 times a second, project the 3D path to 2D, and draw fading trails. Tiny perturbations produce wildly different paths — the classic butterfly effect, made visible.

σ10 ρ28 β2.67 step0.006 point

How it works

Three coupled differential equations: dx/dt = σ(y−x), dy/dt = x(ρ−z) − y, dz/dt = xy − βz. We integrate each step with simple Euler (semi-implicit would be better but Euler is fine at h=0.006). Project 3D to 2D with a rotating camera, draw a 1px line from previous to current point, fade the canvas 4% per frame for trails.

Trade-offs

  • One canvas, ~25 lines of integration + rendering
  • Rotating projection makes the 3D shape obvious without WebGL
  • Euler accumulates error — for hours of integration use RK4

The essential code

lorenz.jsjavascript
const σ = 10, ρ = 28, β = 8 / 3, h = 0.006;
let x = 0.1, y = 0, z = 0;
function tick() {
  for (let i = 0; i < 8; i++) {       // 8 sub-steps per frame
    const dx = σ * (y - x);
    const dy = x *- z) - y;
    const dz = x * y - β * z;
    x += dx * h; y += dy * h; z += dz * h;
    drawSegmentRotated(x, y, z);
  }
  fadeCanvas(0.04);
  requestAnimationFrame(tick);
}

Specs

Integrator
Euler, h=0.006
Sub-steps/frame
8
Projection
3D → 2D rotating
Frame cost
< 1ms
No. 23Algorithm viz · canvas bars

Watch the sorts do their thing.

120 bars of random heights. Pick an algorithm; the comparison loop runs as a generator, yielding one swap per tick so you can see it work. Bubble sort's slow march, quicksort's recursive divide-and-conquer, merge sort's neat halving, insertion's left-to-right sweep — they all leave a different visual fingerprint.

algorithmbubble comparisons0 swaps0 progress0%

How it works

Each algorithm is implemented as a generator function — every yield pauses the sort with the current array state. The render loop pulls one tick per animation frame (or N per frame at "fast" speed). Comparison and swap counters increment inside the algorithm.

Trade-offs

  • Generators give you "pausable" algorithms for free — no manual state machine
  • Same render loop works for any sort that yields the array
  • Highlight active indices (compared / swapped) for visual clarity
  • Generators have small overhead — fine for viz, swap for production

The essential code

sort.jsjavascript
function* bubble(a) {
  for (let i = 0; i < a.length; i++) {
    for (let j = 0; j < a.length - i - 1; j++) {
      cmp++;
      if (a[j] > a[j + 1]) {
        [a[j], a[j + 1]] = [a[j + 1], a[j]]; swp++;
      }
      yield { active: [j, j + 1] };  // pause & let the viewer see
    }
  }
}
const gen = bubble(arr);
function step() { if (gen.next().done) return; render(); requestAnimationFrame(step); }

Specs

Array size
120 values
Algorithms
bubble · insertion · quick · merge
Pause mechanism
JS generators
No. 24Maze generator + A* solver

A maze. Generated, then solved.

Recursive backtracker carves a perfect maze (every cell reachable, no loops). Then A* searches from top-left to bottom-right, expanding nodes with the lowest f = g + h score — Manhattan distance heuristic. The frontier glows orange, visited cells fade blue, the final path lights green.

grid— × — walls0 expanded0 path— cells

How it works

Generation: recursive backtracker — start anywhere, pick a random unvisited neighbour, carve the wall between them, recurse. Backtrack when stuck. Produces a perfect maze with exactly one path between any two cells.

Solving: A* maintains a priority queue ordered by f = g + h where g is the path cost to the current node and h is the Manhattan distance to the goal. Pops the cheapest, expands neighbours, repeats. Guaranteed optimal when h doesn't overestimate.

Trade-offs

  • Recursive backtracker → long winding corridors with high "river" factor
  • Manhattan h is admissible for 4-connected grids — A* is provably optimal
  • Recursion depth = grid area in worst case — switch to iterative stack for huge grids

The essential code

astar.jsjavascript
function astar(start, goal) {
  const open = [{ n: start, g: 0, f: h(start, goal), parent: null }];
  const seen = new Map();
  while (open.length) {
    open.sort((a, b) => a.f - b.f);
    const cur = open.shift();
    if (cur.n === goal) return rebuild(cur);
    seen.set(cur.n, cur.g);
    for (const nb of neighbours(cur.n)) {
      const g = cur.g + 1;
      if ((seen.get(nb) || Infinity) <= g) continue;
      open.push({ n: nb, g, f: g + h(nb, goal), parent: cur });
    }
  }
}

Specs

Generator
Recursive backtracker
Solver
A* + Manhattan h
Connectivity
4-neighbour grid
Optimality
Provably optimal
No. 25Voronoi diagram · interactive seeds

Click to drop seeds. Cells appear around them.

A Voronoi diagram partitions the plane into cells — each cell contains every point closer to its seed than to any other seed. Click anywhere to add a seed; the regions instantly recompute. We use a simple per-pixel nearest-neighbour scan; not the fastest, but the result is exact.

seeds0 pixels/seed0 render0 ms click to add·

How it works

For each pixel, scan every seed, compute Euclidean distance, paint with the nearest seed's color. O(pixels × seeds). For our 400×500 canvas with 30 seeds: 6 million distance checks per render — fine.

The right algorithm for production is Fortune's sweep-line, O((n + m) log n) — but its code length is 5× and the output is identical to the eye for small seed counts.

Trade-offs

  • Naive per-pixel is dead simple to write and debug
  • Cells are pixel-exact — no edge approximation
  • O(width × height × seeds) — slow above ~100 seeds at full resolution

The essential code

voronoi.jsjavascript
function render() {
  const img = ctx.createImageData(W, H);
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      let best = Infinity, bestI = 0;
      for (let i = 0; i < seeds.length; i++) {
        const dx = seeds[i].x - x, dy = seeds[i].y - y;
        const d = dx * dx + dy * dy;     // no sqrt — order is what matters
        if (d < best) { best = d; bestI = i; }
      }
      const p = (y * W + x) * 4;
      img.data[p] = seeds[bestI].r;
      img.data[p + 1] = seeds[bestI].g;
      img.data[p + 2] = seeds[bestI].b;
      img.data[p + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
}

Specs

Algorithm
Naive per-pixel
Distance metric
Euclidean (squared, no sqrt)
Output
ImageData (RGBA buffer)
No. 26True 3D · vertices, matrices, projection

A 3D airplane, actually rendered.

Specimen 12 was a fragment shader — every pixel computed in parallel. This is the other side of the GPU: a vertex shader transforming a hand-authored mesh through model, view, and projection matrices. Drag to orbit. The plane is built from ~40 vertices defining its silhouette + control surfaces.

api vertices draw calls1 fps rotation0°, 0°

How it works

Three matrices, multiplied in order: model (rotate the plane around its center), view (camera looking from +Z), projection (perspective FOV 60°). The vertex shader does gl_Position = P × V × M × position for each vertex. Lines connect them via gl.LINES draw mode.

The plane mesh is ~40 vertices: fuselage (two parallel lines + tail), wing leading and trailing edges with control surfaces, vertical and horizontal stabilizers.

Trade-offs

  • Hand-authored mesh — no model loader, no obj parser
  • gl.LINES + one draw call — fastest possible
  • Matrix multiplication done in JS (only 3 mults per frame)
  • No depth test on lines — far edges overlap near edges

The essential code

plane.glslglsl
attribute vec3 a_pos;
uniform mat4 u_mvp;
void main() {
  gl_Position = u_mvp * vec4(a_pos, 1.0);
}
plane.jsjavascript
function frame() {
  const M = rotateY(rotY).rotateX(rotX);
  const V = translate(0, 0, -6);
  const P = perspective(60, aspect, 0.1, 100);
  gl.uniformMatrix4fv(uMVP, false, P.mul(V).mul(M).flat());
  gl.drawArrays(gl.LINES, 0, vertexCount);
}

Specs

API
WebGL 1.0
Mesh
~40 vertices, hand-authored
Draw call
1 per frame (LINES)
Matrix math
JS (~50 lines, no library)
No. 27CSS 3D · transform-style: preserve-3d

A cube. Built entirely in CSS.

Six div faces, each rotated and pushed forward 100px. The parent has transform-style: preserve-3d, so their Z values stack in real space rather than collapsing flat. Drag to rotate; JS just sets two CSS custom properties.

faces6 rotation-20°, 30° perspective1000px backdrop-filterblur(2px)
+Z
−Z
+X
−X
+Y
−Y

How it works

Each face is positioned at inset: 0 on the parent, then transformed via a specific rotation + translateZ(100px). Front face: just translateZ(100px). Right face: rotateY(90deg) translateZ(100px) — rotate first, then push along the new local Z. The parent's transform-style: preserve-3d keeps each face in its own 3D plane.

Trade-offs

  • Six divs — accessible, screen-readable, selectable text on faces
  • Compositor-friendly — all transforms run off the main thread
  • Backdrop-filter on faces gives them glass-like depth
  • No depth sort — back faces show through; use backface-visibility: hidden if needed

The essential code

cube.csscss
.stage { perspective: 1000px; }
.cube  {
  transform-style: preserve-3d;
  transform: rotateX(var(--rx)) rotateY(var(--ry));
}
.face         { position: absolute; inset: 0; }
.f-front      { transform: translateZ(100px); }
.f-back       { transform: rotateY(180deg) translateZ(100px); }
.f-right      { transform: rotateY(90deg)  translateZ(100px); }
.f-left       { transform: rotateY(-90deg) translateZ(100px); }
.f-top        { transform: rotateX(90deg)  translateZ(100px); }
.f-bottom     { transform: rotateX(-90deg) translateZ(100px); }

Specs

Tech
CSS transforms only
JS
~20 lines (drag → CSS vars)
Browser support
97% (preserve-3d)
No. 28Drawing + audio · waveform synthesis

Draw a wave. Hear it.

Sketch any curve in the panel — the line you draw becomes one cycle of an audio waveform. We sample your curve into an AudioBuffer and loop it as a wavetable through Web Audio. Sine becomes pure, square becomes harsh, sawtooth becomes bright. Make a mess and hear noise.

samples512 fundamental220 Hz apiAudioBufferSourceNode statedraw a wave

How it works

The canvas is treated as the X-axis of one waveform cycle. As you draw, we sample 512 evenly-spaced X coordinates, read the Y at each (normalized to [-1, +1]), and write them into an AudioBuffer. An AudioBufferSourceNode loops that buffer at the right rate to play at 220Hz (A3).

Because the buffer is exactly one cycle long, looping it produces a continuous periodic wave at the loop frequency. The shape you drew becomes the harmonic content.

Trade-offs

  • One AudioBuffer, looped — efficient and click-free
  • 512 samples is enough for ~22 harmonics at 220Hz × 48kHz
  • Hard discontinuities click — smooth the buffer edges to match

The essential code

sketch.jsjavascript
const N = 512, freq = 220;
const buf = ctx.createBuffer(1, N, freq * N);   // 1 cycle = N samples
const data = buf.getChannelData(0);
for (let i = 0; i < N; i++) {
  const x = i / N * canvas.width;
  data[i] = sampleSketchY(x);              // returns -1..1
}
const src = ctx.createBufferSource();
src.buffer = buf; src.loop = true;
src.connect(masterGain).connect(ctx.destination);
src.start();

Specs

Wavetable size
512 samples
Fundamental
220 Hz (A3)
Looping
AudioBufferSourceNode.loop = true
Audio assets
0 bytes
No. 29QR code generator · pure JS, no library

Type anything. Get a QR code.

A complete QR encoder — Reed–Solomon error correction, mask pattern selection, the works — in about 400 lines of self-contained JavaScript. Type text below; the canvas regenerates as you type. Scan it with any phone.

version modules EC levelM
Tip: short URLs encode at lower QR versions — denser, easier to scan.

How it works

QR encoding is a chain: text → byte array → padding bits → Reed–Solomon ECC blocks → interleaved bit stream → 2D module placement (finder patterns, alignment, timing) → mask selection by penalty scoring → render.

The Reed–Solomon step is the cleverest. It builds a generator polynomial in GF(256) (the Galois field of 256 elements) and divides the data polynomial by it; the remainder is the ECC bytes that let scanners recover from up to 15% damage at error-correction level M.

Trade-offs

  • Self-contained — no library, no CDN, no SVG
  • Canvas output scales with image-rendering: pixelated
  • Supports byte mode (ASCII or URL-encoded UTF-8)
  • Numeric and alphanumeric modes pack tighter — implementing them adds ~150 lines

The essential code

qr.js (excerpt)javascript
// Reed-Solomon over GF(256) — the heart of QR
function rsCompute(data, ecLen) {
  const gen = rsGenerator(ecLen);
  const r = new Uint8Array(data.length + ecLen);
  r.set(data);
  for (let i = 0; i < data.length; i++) {
    const coef = r[i];
    if (coef !== 0) {
      const log = GF_LOG[coef];
      for (let j = 0; j < gen.length; j++)
        r[i + j] ^= GF_EXP[(log + gen[j]) % 255];
    }
  }
  return r.slice(data.length);
}

Specs

Encoder
Hand-rolled, ~400 lines
Mode
Byte (URL/ASCII)
EC level
M (~15% recovery)
Output
Canvas (scalable, pixelated)
No. 30OKLCH · color-mix() · modern color CSS

Color, the perceptually-uniform way.

Drag the L/C/H sliders. The left chip shows your OKLCH color, the middle chip shows a 50/50 mix with the brand cyan via color-mix(in oklch, …), the right chip shows the same mix in plain sRGB. Notice how the OKLCH mix stays vibrant where sRGB goes muddy — perceptual uniformity in one image.

spaceoklch apicolor-mix() browser contrast
Your color
50% mix · oklch
50% mix · sRGB
Lightness · 0.62
Chroma · 0.18
Hue · 260°

How it works

OKLCH = Oklab in cylindrical form: Lightness (perceptual 0..1), Chroma (vividness 0..0.37), Hue (0–360°). It was designed to match human vision — a step in L looks like an equal step in brightness, unlike HSL where 50% looks darker than 50% should.

color-mix(in oklch, var(--a) 50%, var(--b)) interpolates in OKLCH space. The midpoint between blue and yellow becomes a clean cyan-green — not the muddy grey-brown that sRGB interpolation produces.

Trade-offs

  • Pure CSS — set custom properties from sliders, browser does the math
  • Perceptual uniformity = predictable design tokens
  • color-mix() is ~98% supported as of 2026
  • Out-of-gamut OKLCH values clip to sRGB — high chroma can lose vividness

The essential code

color.csscss
.chip-base { background: oklch(var(--l) var(--c) var(--h)); }

.chip-mix-oklch {
  background: color-mix(in oklch,
    oklch(var(--l) var(--c) var(--h)) 50%,
    oklch(82% 0.15 190)         /* brand cyan */
  );
}

.chip-mix-srgb {
  background: color-mix(in srgb,
    oklch(var(--l) var(--c) var(--h)) 50%,
    #40e6d9
  );
}

Specs

Color space
OKLCH (Oklab cylindrical)
Interpolation
oklch vs sRGB · side-by-side
Browser support
~98% (oklch + color-mix)
No. 31Markdown · 80-line parser, live preview

Type Markdown. See HTML. No library.

A complete (well, common-subset) Markdown parser in ~80 lines. Headings, lists, code blocks, inline code, bold/italic, links, blockquotes, paragraphs. Type on the left and the right side re-renders as you go. Sanitized — no script tags, no inline event handlers survive.

parser lines~80 render time— ms output bytes— B XSS-safeescape + allowlist

How it works

Block pass: split by double newlines, classify each block (heading by leading #, list by -, code by ```, quote by >, else paragraph). Inline pass per block: replace **x** → bold, *x* → italic, `x` → code, [txt](url) → anchor. Escape < > & first so user content can never inject HTML.

Trade-offs

  • ~80 lines, no dependencies, syntax-highlighted output
  • Escapes HTML first — XSS-safe even with malicious input
  • Two-pass design (blocks then inlines) keeps the regex tame
  • No tables, no footnotes, no autolinking URLs — full CommonMark would be 10× the code

The essential code

md.jsjavascript
function parse(src) {
  const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  const inline = s => esc(s)
    .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
    .replace(/\*([^*]+)\*/g,     '<em>$1</em>')
    .replace(/`([^`]+)`/g,       '<code>$1</code>')
    .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
  // block pass: heading / list / code / quote / paragraph
  return src.split(/\n\n+/).map(b => block(b, inline)).join('');
}

Specs

Parser size
~80 lines
Sanitization
HTML escape first → safe
Render
innerHTML on parsed output
No. 32Force-directed graph · canvas simulation

A network that settles itself.

14 nodes, 20 edges, all under three forces: edges act as springs pulling connected nodes together, every pair repels each other (so the graph doesn't collapse), and a gentle centering force keeps everything on screen. Drag any node and watch the rest rearrange.

nodes14 edges20 kinetic energy0.00 drag

How it works

Each frame: compute the net force on every node. Spring force on each edge: F = k(distance − restLength) toward the other end. Repulsion between every pair of nodes: F = q/distance² like Coulomb's law (charge q is small and constant). Centering: a tiny pull toward the canvas center. Integrate velocity, then position, with a 0.92 damping multiplier so the graph eventually settles.

Trade-offs

  • O(n²) per frame — fine for ≤ 100 nodes
  • Damping ensures convergence — no perpetual oscillation
  • Dragged node has its velocity zeroed each frame — the simulation responds to you instead of fighting you
  • For 10K+ nodes, switch to Barnes-Hut tree for O(n log n) repulsion

The essential code

force.jsjavascript
function tick() {
  // 1. repulsion between every pair
  for (let i = 0; i < nodes.length; i++) {
    for (let j = i + 1; j < nodes.length; j++) {
      const dx = nodes[j].x - nodes[i].x;
      const dy = nodes[j].y - nodes[i].y;
      const d2 = dx * dx + dy * dy + 10;
      const f = 800 / d2;
      nodes[i].vx -= dx * f; nodes[i].vy -= dy * f;
      nodes[j].vx += dx * f; nodes[j].vy += dy * f;
    }
  }
  // 2. spring force along edges
  for (const e of edges) {
    const a = nodes[e[0]], b = nodes[e[1]];
    const dx = b.x - a.x, dy = b.y - a.y;
    const d = Math.hypot(dx, dy) || 1;
    const f = (d - 90) * 0.02;             // k = 0.02, rest = 90
    a.vx += dx / d * f; a.vy += dy / d * f;
    b.vx -= dx / d * f; b.vy -= dy / d * f;
  }
  // 3. integrate with damping
  for (const n of nodes) {
    n.vx *= 0.92; n.vy *= 0.92;
    n.x += n.vx; n.y += n.vy;
  }
}

Specs

Forces
spring · repulsion · centering
Damping
0.92 per frame
Complexity
O(n²) repulsion
No. 33Particle attractor · cursor gravity

Your cursor is a gravity well.

800 particles drift on a slow random walk. Move your cursor over the canvas — it becomes a point mass pulling them in. Click to invert it into a repeller. The trail effect comes from a 4% black fade each frame, same trick as the flow field.

particles800 cursor modeattract cursor (px)—, — fps

How it works

Each particle has position + velocity. Per frame: compute vector to cursor, apply inverse-square attraction (clamped at small distance to prevent runaway), add tiny random jitter, integrate with damping. Trail by fading the canvas instead of clearing it.

Trade-offs

  • O(n) per frame — scales to a few thousand particles
  • Single fillRect per frame for trails — cheap and luminous
  • Distance clamp prevents particles from accelerating to infinity
  • Per-pixel canvas trails accumulate banding — periodic full clears help

The essential code

gravity.jsjavascript
function tick() {
  ctx.fillStyle = 'rgba(7,7,13,0.10)';       // fade for trails
  ctx.fillRect(0, 0, w, h);
  ctx.fillStyle = 'rgba(64,230,217,0.85)';
  for (const p of ps) {
    const dx = mouse.x - p.x;
    const dy = mouse.y - p.y;
    const d2 = Math.max(100, dx * dx + dy * dy);   // clamp
    const f = mode * 200 / d2;                  // mode = ±1
    p.vx = (p.vx + dx * f) * 0.92;
    p.vy = (p.vy + dy * f) * 0.92;
    p.x += p.vx; p.y += p.vy;
    ctx.fillRect(p.x, p.y, 2, 2);
  }
  requestAnimationFrame(tick);
}

Specs

Particles
800
Force law
~1/r² (clamped)
Click
toggle attract/repel
No. 34Pool physics · circle-circle collision

A pool table, fully simulated.

Eight balls on green felt. Drag from the cue ball to aim and set power; release to shoot. Every pair of balls is checked for overlap each frame; collisions resolve via elastic impulse along the normal. Cushions reflect velocity. Friction takes over and slows everyone down.

balls8 collisions0 friction0.992 stateready

How it works

Each frame: integrate velocities, multiply by friction (0.992 — gentle, mimics felt). Check every pair of balls for overlap (distance < sum of radii). For each overlap, push them apart along the normal and exchange velocity components along that normal — that's the elastic collision in 1D, applied along the contact axis. Cushion collision is just clamping position and negating the appropriate velocity component.

Trade-offs

  • O(n²) collision check — trivial at 8 balls, swap to a grid for ~100+
  • Equal-mass elastic collision is one line of math (swap normal components)
  • Friction is multiplicative — exponential decay, never reaches zero
  • No spin / no English / no tangential friction — just kinematic billiards

The essential code

pool.jsjavascript
function collide(a, b) {
  const dx = b.x - a.x, dy = b.y - a.y;
  const d = Math.hypot(dx, dy);
  if (d > a.r + b.r) return;
  // 1. push apart along normal
  const overlap = (a.r + b.r - d) / 2;
  const nx = dx / d, ny = dy / d;
  a.x -= nx * overlap; a.y -= ny * overlap;
  b.x += nx * overlap; b.y += ny * overlap;
  // 2. swap velocity components along normal (equal mass elastic)
  const aN = a.vx * nx + a.vy * ny;
  const bN = b.vx * nx + b.vy * ny;
  const diff = bN - aN;
  a.vx += diff * nx; a.vy += diff * ny;
  b.vx -= diff * nx; b.vy -= diff * ny;
}

Specs

Collision detection
Pairwise O(n²)
Collision response
Elastic, equal mass
Friction
0.992× per frame
No. 35Multi-touch · pinch and rotate

Pinch to zoom. Twist to rotate.

Two-finger gesture handling on a touch screen, or two-button drag on desktop. The card tracks the centroid of your two contact points; pinch changes its scale, twist changes its rotation. Native Pointer Events — no library, no gesture detection logic beyond high-school trig.

touches0 scale1.00× rotation centroid—, —
SPECIMEN 35
pinch · twist · drag

How it works

Track active pointers in a Map keyed by pointerId. With two pointers active: centroid = average position, distance = ‖p2 − p1‖, angle = atan2(Δy, Δx). Each frame compute the delta from the initial centroid/distance/angle and apply: translate(centroidΔ) scale(distΔ) rotate(angleΔ).

Trade-offs

  • Pointer Events unify mouse, pen, and touch — one code path
  • Math is trivial — centroid, hypot, atan2
  • touch-action: none on the stage prevents browser-native pan/zoom
  • Three-finger gestures need extending the map logic

The essential code

multitouch.jsjavascript
const active = new Map();
stage.addEventListener('pointerdown', e => active.set(e.pointerId, { x: e.clientX, y: e.clientY }));
stage.addEventListener('pointermove', e => {
  if (!active.has(e.pointerId)) return;
  active.set(e.pointerId, { x: e.clientX, y: e.clientY });
  if (active.size >= 2) {
    const [p1, p2] = [...active.values()];
    const dx = p2.x - p1.x, dy = p2.y - p1.y;
    const dist  = Math.hypot(dx, dy);
    const angle = Math.atan2(dy, dx);
    const cx    = (p1.x + p2.x) / 2;
    const cy    = (p1.y + p2.y) / 2;
    applyGesture(cx, cy, dist, angle);
  }
});

Specs

API
Pointer Events
Touches
2 (extends to N)
Math
centroid · hypot · atan2
Touch policy
touch-action: none
No. 36Pressure-sensitive drawing · stylus / pen

Press harder. Draw thicker.

Draw anywhere in the panel. On a trackpad or mouse this is a constant-width pen. On an Apple Pencil, Wacom tablet, or supported stylus, the line width responds to PointerEvent.pressure — the same API that lets web apps approach native illustration tools.

pointerType pressure0.00 tiltX, tiltY0°, 0° samples0

How it works

On each pointermove with the button down, read e.pressure (0..1 — defaults to 0.5 for mouse). Multiply by max line width (12px). Draw a circle at the new position, line-to from the previous point with the new width. Pressure on a real stylus typically ranges 0.2–0.9; we map that to 2px → 12px.

Trade-offs

  • Native Pointer Events — works on Apple Pencil, S Pen, Wacom, more
  • Mouse gets a sane default (0.5) so it still draws
  • tiltX/tiltY are available too — calligraphic strokes possible
  • Variable width strokes need per-segment fills, not lineWidth changes

The essential code

draw.jsjavascript
canvas.addEventListener('pointermove', e => {
  if (!drawing) return;
  const pressure = e.pressure || 0.5;        // fallback for non-stylus
  const width = 2 + pressure * 10;
  ctx.beginPath();
  ctx.moveTo(prev.x, prev.y);
  ctx.lineTo(e.offsetX, e.offsetY);
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.lineCap = 'round';
  ctx.stroke();
  prev = { x: e.offsetX, y: e.offsetY };
});

Specs

API
PointerEvent.pressure / tiltX / tiltY
Pressure range
0..1 (mouse default 0.5)
Width range
2px → 12px
Devices
Apple Pencil, Wacom, S Pen, MS Pen
No. 37Aviation · crosswind component calculator

A pilot's tool, made interactive.

Every GA pilot does this math on every takeoff: how much of the reported wind is crosswind versus headwind? Spin the runway dial, set wind direction and speed — the dial draws the wind vector, the components fall out from sine and cosine. Cross-checks against the aircraft's published demonstrated crosswind limit.

30 12
Headwind component
kt
Crosswind component
kt
Wind angle off runway
°
Runway heading · 300°
Wind from · 270°
Wind speed · 15 kt

How it works

Let θ = wind direction − runway heading (positive = wind from the right). Then headwind = speed · cos(θ) (negative means tailwind), crosswind = speed · sin(θ) (sign tells you which side). The SVG dial rotates a runway centreline + a wind arrow into the right positions.

Trade-offs

  • Two lines of trig produce the whole thing
  • SVG = vector dial, infinitely zoomable
  • Headwind goes red when negative (tailwind = danger)
  • Crosswind goes red above 20kt (most light-aircraft limits)

The essential code

crosswind.jsjavascript
function compute(runwayHdg, windDir, windSpd) {
  const θ = ((windDir - runwayHdg + 540) % 360) - 180;   // −180..+180
  const rad = θ * Math.PI / 180;
  return {
    headwind:  windSpd * Math.cos(rad),    // negative = tailwind
    crosswind: windSpd * Math.sin(rad),    // sign = which side
    angleOff:  θ,
  };
}

Specs

Math
sin/cos of (wind − runway)
Display
SVG vector dial
Pilot use
Every takeoff and landing
No. 38Aviation · density altitude calculator

How high the airplane thinks it is.

Density altitude = pressure altitude corrected for non-standard temperature. It tells your wings, your engine, and your propeller how dense the air actually is — and how much performance you've lost. Slide pressure altitude and outside temperature; the readout updates with the standard formula and a color band for the safety margin.

ft DA
ISA deviation:
Std temp at this PA:
Engine loss approx:
Pressure altitude · 3,000 ft
Outside air temp · 25 °C

How it works

Standard temperature at pressure altitude: 15 − 1.98 × (PA / 1000) °C. ISA deviation = actual OAT − standard. Density altitude ≈ PA + 120 × ISA deviation ft — the rule-of-thumb formula every pilot memorizes.

Engine performance drops about 3% per 1,000 ft of DA above sea level — important for takeoff at high airports on hot days (think KASE, KTEX, KLXV in summer).

Trade-offs

  • Two-line formula reproduces a $200 E6B flight computer
  • Color band gives instant safety read
  • Engine-loss estimate makes the abstract number actionable
  • Doesn't account for humidity — DA can be ~500ft higher on a humid day

The essential code

density.jsjavascript
function densityAltitude(pa, oat) {
  const stdTemp = 15 - 1.98 * (pa / 1000);   // ISA lapse 1.98°C / 1000ft
  const isaDev = oat - stdTemp;
  const da     = pa + 120 * isaDev;             // rule of thumb
  return { stdTemp, isaDev, da };
}

Specs

ISA lapse rate
1.98 °C / 1000 ft
DA formula
PA + 120 × ISA dev
Engine loss
~3% per 1000 ft DA
No. 39Solar position · sunrise/sunset for any city

The sun, traced across today's sky.

Pick a city. The arc shows the sun's path through today's sky — from sunrise on the left horizon, peaking at solar noon, setting on the right. The disc marks where the sun is right now. All computed from the date and lat/lon via the NOAA solar position algorithm — no API call.

East West South
City
Sunrise
Solar noon
Sunset

How it works

NOAA's algorithm: compute the Julian day, derive the sun's declination and equation of time, then hour angle from the desired event (sunrise = sun crossing the horizon = altitude 0). Apply the location's latitude. The arc shape comes from sampling altitude every 30 minutes through the day and projecting onto the SVG (x = time linearly, y = inverse of altitude).

Trade-offs

  • Pure JS — no astronomy library, no API call
  • Accurate to within ~1 minute for any location, any date
  • Same math powers astronomy apps and sun-tracker solar mounts
  • Doesn't account for atmospheric refraction — true sunrise is ~34' earlier than geometric

The essential code

solar.js (NOAA, simplified)javascript
function sunriseSunset(date, lat, lon) {
  const jd  = julianDay(date);
  const t   = (jd - 2451545) / 36525;            // Julian centuries J2000
  const dec = solarDeclination(t);                    // degrees
  const eqt = equationOfTime(t);                      // minutes
  const hourAngle = Math.acos(
    Math.cos(rad(90.833)) / (Math.cos(rad(lat)) * Math.cos(rad(dec)))
    - Math.tan(rad(lat)) * Math.tan(rad(dec))
  );
  const noon    = 720 - 4 * lon - eqt;                  // minutes UTC
  return {
    sunrise: noon - deg(hourAngle) * 4,
    noon,
    sunset:  noon + deg(hourAngle) * 4,
  };
}

Specs

Algorithm
NOAA solar position
Accuracy
±1 minute (most latitudes)
API calls
0
No. 40Aviation · TAF decoder · live data

A TAF in plain English.

A Terminal Aerodrome Forecast (TAF) is a 24-hour cryptic forecast issued for every major airport. KMSP's looks like TAF KMSP 091720Z 0918/1018 30015KT P6SM FEW040 BKN250. The decoder below pulls today's TAF for any airport from the studio's worker proxy and decodes each segment into a human-readable line. Pairs with specimen 07 (METAR).

stationKMSP api/api/public/taf latency periods
Loading…

How it works

Same worker pattern as the METAR proxy: /api/public/taf?icao=KMSP proxies aviationweather.gov, edge-caches the response for 60s, returns the raw TAF plus normalized period objects. The decoder splits on FM/BECMG/TEMPO tokens, parses wind (30015KT), visibility (P6SM), and clouds (BKN250) from each segment.

Trade-offs

  • Edge cache absorbs 99% of traffic from upstream
  • Decoder handles the common 80% of TAF grammar — wind, vis, clouds, weather
  • ICAO validated ^[A-Z]{4}$ — no SSRF surface
  • Doesn't decode every PROB30 / TEMPO sub-clause — those need a full grammar

The essential code

taf-decode.jsjavascript
function decodeSegment(seg) {
  const out = {};
  const wind = seg.match(/(\d{3}|VRB)(\d{2,3})(G(\d{2,3}))?KT/);
  if (wind) {
    out.wind = wind[1] === 'VRB' ? 'variable' : wind[1] + '°';
    out.wind += ' @ ' + wind[2] + 'kt' + (wind[4] ? ' G' + wind[4] : '');
  }
  const vis = seg.match(/\bP?(\d{1,2})SM\b/);
  if (vis) out.vis = vis[0].replace('P', '>');
  const clouds = [...seg.matchAll(/(FEW|SCT|BKN|OVC)(\d{3})/g)];
  out.clouds = clouds.map(c => decodeCloud(c[1], +c[2] * 100)).join(', ');
  return out;
}

Specs

Endpoint
/api/public/taf?icao=…
Edge TTL
60s
Decoder coverage
Wind · vis · clouds · weather
No. 41CSS Anchor Positioning · popovers without JS

Tooltips that know where their trigger is.

Tap any pill. A popover floats above it — positioned by the browser via CSS Anchor Positioning, not by a JavaScript getBoundingClientRect calculation. Resize the window, scroll the page: the popover follows. Chrome 125+; falls back to plain absolute positioning elsewhere.

api implementationanchor-name + position-anchor JS positioning lines0
Today every tooltip library re-implements the same positioning math. This API hands that math to the browser — and the browser knows about viewport, scrolling, and overflow in ways JS can't match.
Three properties: anchor-name on the trigger, position-anchor + position-area on the popover. The browser positions the popover relative to the named anchor automatically.
Chrome 125+ and Edge 125+ (April 2024). Safari 18.4 partially supports. Firefox tracking. Fallback: position: absolute with manual top/left.

How it works

Three CSS properties replace dozens of lines of JS positioning. The trigger names itself with anchor-name: --a1. The popover references it with position-anchor: --a1 and picks a side via position-area: top. Combine with the popover attribute (HTML platform native popovers) for click-to-open and click-outside-to-close.

Trade-offs

  • Zero JS positioning — browser handles viewport, scroll, overflow
  • Pairs perfectly with the native popover attribute
  • Anchor scoping means a tooltip can follow its trigger anywhere on the page
  • Chrome 125+ / Edge 125+ — Firefox still unimplemented

The essential code

anchor.csscss
/* Trigger names itself */
.trigger { anchor-name: --tip; }

/* Popover references that name */
.popover {
  position-anchor: --tip;
  position-area: top;          /* sits above the anchor */
  margin-bottom: 8px;
}
anchor.htmlhtml
<button popovertarget="tip">Trigger</button>
<div id="tip" popover>Tooltip body</div>

Specs

API
anchor-name · position-anchor · position-area
Browser support
Chromium 125+ ~
JS shipped
0 (just the popover attr)
No. 42Container queries · component-level responsive

One component. Three layouts.

The same card markup appears in three containers of different widths. Each container declares container-type: inline-size; the card's CSS rules query its container's width with @container card (min-width: 280px) and rearrange itself accordingly. The viewport is irrelevant — the card responds to its own slot.

Narrow column
Weather.Co
Aviation weather for GA pilots
Medium column
Weather.Co
Aviation weather for GA pilots
Wide column
Weather.Co
Aviation weather for GA pilots — the same card markup, three different layouts driven by container width.
technique@container declarations3 browser~96%

How it works

The container declares its containment behaviour: container-type: inline-size means "I am a container; descendants can query my inline (width) dimension." The card then queries that container with @container (min-width: 280px) { … } blocks. Three queries → three layouts, all driven by the container's actual rendered width.

Trade-offs

  • Truly modular components — they own their responsive logic
  • Works inside any grid, flex, or absolute layout
  • Replaces ResizeObserver-driven JS layout patterns
  • container-type adds layout containment — careful with elements that expect to expand their parent

The essential code

container.csscss
.container {
  container-type: inline-size;
  container-name: card;
}
.card { display: flex; flex-direction: column; gap: 8px; }

@container card (min-width: 280px) {
  .card { flex-direction: row; align-items: center; gap: 14px; }
  .card .icon { width: 56px; height: 56px; }
}
@container card (min-width: 400px) {
  .card .icon { width: 72px; height: 72px; }
  .card .title { font-size: 20px; }
}

Specs

API
container-type + @container
Browser support
~96% (2024+)
JS required
0
No. 43OffscreenCanvas · canvas rendering on a Worker thread

Canvas rendering, off the main thread.

A heavy fractal renderer would normally lock up your interactions while it draws. Here we transfer the canvas to a Web Worker and let it render on a background thread — the main thread stays free, scroll stays smooth, the FPS counter on this card keeps ticking. Mandelbrot rendered live on a worker.

main thread fps worker fps api iterations200
Drag the canvas to pan — the worker re-renders without blocking this page.

How it works

The main thread creates a Web Worker (inlined here via Blob URL) and calls canvas.transferControlToOffscreen(), which moves ownership of the canvas to the worker. The worker uses normal canvas2d APIs to render — but every operation runs on the worker thread, never blocking the main thread's UI.

Trade-offs

  • Main thread stays responsive — scrolling, animation, input all unaffected
  • Worker can use Canvas API, ImageData, requestAnimationFrame
  • One-time transfer — main thread can't draw to the canvas afterward
  • postMessage roundtrips per interaction — chunky for high-frequency UI
  • No DOM in the worker — debug logs only via postMessage

The essential code

main.jsjavascript
// Main thread: create worker, hand it the canvas
const worker = new Worker(blobUrl);
const off = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: off }, [off]);

worker.postMessage({ cmd: 'pan', dx: 10, dy: 5 });   // later commands
worker.jsjavascript
onmessage = (e) => {
  if (e.data.canvas) ctx = e.data.canvas.getContext('2d');
  // can use ctx like a normal 2D context — but off the main thread
  renderMandelbrot();
};

Specs

API
OffscreenCanvas + Worker
Worker source
Blob URL (inlined, no extra file)
Browser support
96%+ (2023+)
No. 44WebCrypto · cryptographic hashes in the browser

SHA-256, per keystroke.

Type anything below. The browser hashes it via the WebCrypto SubtleCrypto API — the same primitive that powers HTTPS handshakes, JWT signatures, and password derivations. Try changing one character and watch every output byte flip. Tab between SHA-256, SHA-384, SHA-512, SHA-1.

Input
Hash (hex)
apicrypto.subtle.digest algorithmSHA-256 input bytes latency

How it works

Encode the string to bytes with a TextEncoder. Pass the buffer to crypto.subtle.digest(algo, bytes), await the result, convert the ArrayBuffer to a hex string. The actual hashing happens in compiled, audited browser code — the same implementation TLS uses.

Trade-offs

  • Real crypto, real audits — no DIY hash function
  • Async API — never blocks the main thread, even for huge inputs
  • Same primitive for HMAC, ECDH, AES, ECDSA — full crypto kit available
  • SHA-1 included for show — never use it for security in new code

The essential code

hash.jsjavascript
async function hash(text, algo = 'SHA-256') {
  const bytes = new TextEncoder().encode(text);
  const buf   = await crypto.subtle.digest(algo, bytes);
  return [...new Uint8Array(buf)]
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

Specs

API
crypto.subtle.digest
Algorithms
SHA-1, 256, 384, 512
Latency
~0.1ms for short strings
Browser support
Universal (since 2017)

Like what you see? Let's build something.

Every specimen above is shippable. Most are 20–60 lines of code. If you want this kind of polish on your own product, the studio is taking on a small number of projects.

Start a project →
Specimens
44
Page weight
~480KB
JS shipped
~165KB
Frameworks
0