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
- —
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.
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
// 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 });
.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 ~
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.
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-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 ✓
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.
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
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 ✓
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.
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
<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-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)
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.
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
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)
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.
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
.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); }
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)
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.
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
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
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.
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
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)
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.
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
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 ✓
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.
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
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 ✓
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.
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
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)
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.
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
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
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.
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: noneon the stage to prevent scroll
The essential code
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
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.
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
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); } }
.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
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.
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.
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
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'); });
.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
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.
Aircraft in range connecting…
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
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
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.
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
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
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.
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
@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
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.
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
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 ✓
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.
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
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 ✓
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.
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
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
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.
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
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 ✓
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.
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
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
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.
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
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 ✓
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.
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
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)
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.
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
attribute vec3 a_pos; uniform mat4 u_mvp; void main() { gl_Position = u_mvp * vec4(a_pos, 1.0); }
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)
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.
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: hiddenif needed
The essential code
.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)
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.
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
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
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.
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
// 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)
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.
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
.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)
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.
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
function parse(src) { const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 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
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.
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
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
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.
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
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
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.
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
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
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.
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
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
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.
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
lineWidthchanges
The essential code
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
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.
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
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
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.
Std temp at this PA: —
Engine loss approx: —
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
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
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.
- 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
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
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).
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
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
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.
anchor-name on the trigger, position-anchor + position-area on the popover. The browser positions the popover relative to the named anchor automatically.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
popoverattribute - Anchor scoping means a tooltip can follow its trigger anywhere on the page
- Chrome 125+ / Edge 125+ — Firefox still unimplemented
The essential code
/* 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; }
<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)
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.
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 {
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
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.
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 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
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+)
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.
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
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