It's raining, it's pouring, the droplets are no longer boring

The raindrop animation on this site was always meant to be interactive. Back when I first got into building websites, I wanted to pull in the weather data from wherever I lived at the time to make the weather on my portfolio match the real world. Eventually I settled on a static rain effect that was always there, but you couldn't play with it like I wanted.

That bothered me for years. Meanwhile, every kid who has ever ridden in the back seat on a rainy day knows exactly what raindrops on glass are for. You pick a drop. You root for it. It swallows its neighbors and fattens up and makes a break for the bottom of the window. Undefeated entertainment since the invention of the car window—iPad kids will never understand.

The rain, before

Quick anatomy lesson. The effect is two layers pretending to be one:

  • A simulation of droplet sprites in PIXI ParticleContainer object pools—about 9,000 small static droplets and 200 large ones that trickle, jitter, randomly stall, and plunge when they get heavy. Sprites get recycled constantly; nothing is ever allocated mid-frame.
  • A fragment shader that never draws "water." It reads the droplet sprites as data and refracts a background photo through them, adds a shine highlight, and blends. The droplets you see are literally the background image bent through math.

Which means the first problem of making it interactive: there is nothing to click. The "droplets" are pixels the shader invented, the DOM doesn't know idea they exist.

Finding the drop under your finger

The canvas sits behind the entire portfolio UI, so slapping a click handler on it was never going to work—the header, links, and page content are stacked on top with higher z-indexes, and they need first dibs on every event.

Instead, the rain listens at the window boundary with capture-phase listeners, and then aggressively disqualifies itself:

  • Is the event targeting a link, button, input, textarea, select, or anything with role="button"? Not our event. Walk away.
  • Is the press on empty sky (no droplet under it)? Not our event. Walk away.
  • Only a direct hit on a live, moving, unclaimed large droplet starts a session—and only then does the rain call preventDefault() on anything.

Hit testing itself has a subtlety: the shader's refracted footprint is bigger than the sprite radius the simulation uses for collisions. Add a real finger to that (a thumb is not a cursor) and exact-radius hit testing feels broken even when it's mathematically correct. So pickup uses a forgiving ellipse:

// The shader's refracted footprint is larger than the collision radius // used by the autonomous simulation. Use a forgiving elliptical pickup // region so real fingers and trackpads can acquire the visible drop. const radiusX = Math.max(8, droplet.width / 3); const radiusY = Math.max(8, droplet.height / 3); const dx = (x - droplet.x) / radiusX; const dy = (y - droplet.y) / radiusY; const distance = dx * dx + dy * dy; if (distance <= 1 && distance < hitDistance) { hit = droplet; hitDistance = distance; }

Overlapping droplets resolve by distance, then by draw order—ties go to the droplet on top, because that's the one you think you're touching. And the cursor tells you the truth the whole time: grab over a droplet, grabbing while you hold one.

One pointer, one droplet

A drag session is exclusive: the pointer that picked the droplet up owns it. A second finger, a right click, a pen—none of them can move or release someone else's droplet. The droplet keeps its original grab offset too, so it doesn't snap its center to your finger; you hold it where you caught it.

While held, a droplet stops being a citizen of the simulation: gravity, jitter, random stalls, and—learned this one the fun way—offscreen recycling all pause. Drag a droplet past the screen edge and the cleanup pass would happily recycle it out of your hand mid-drag, and now you're holding a pointer to a droplet that just respawned somewhere else as a different raindrop. Held droplets are exempt until you let go.

The touch event betrayal

Here's the bug that ate an evening. Modern browsers fire pointer events and touch events for the same finger, and the synthesized pointerdown arrives before touchstart. My pointer handler would claim the touch first with its numeric pointer ID. Then the touch handlers—which are the only handlers that can preventDefault() a scroll—would look for a session under a touch ID, find nothing, and stand down. Net result: you grab a droplet, the browser simultaneously decides you're scrolling the page, fires pointercancel, and yoinks the droplet out of your hand.

The fix is one guard: the pointer path ignores touches entirely and lets the touch handlers own them.

// Touch contacts are claimed by the touch handlers, which fire after the // synthesized pointer events and must own preventDefault to stop the // browser from starting a scroll or zoom gesture mid-drag. if (event.pointerType === "touch") { return; }

Releasing outside the window

Second gotcha: press a droplet, drag your mouse out of the browser window, release. Congratulations—pointerup never fires, because you released over your desktop. Without a fix, the site thinks you're still dragging forever. But the next time your mouse wanders back in, the move event confesses that no buttons are pressed:

// A mouse released outside the browser window never delivers pointerup; // the first move that reports no pressed buttons ends the session. if (event.pointerType === "mouse" && event.buttons === 0) { this.endActiveSession({ cancelled: false, timeMs: event.timeStamp }); return; }

Every way a session can die—release, pointercancel, touchcancel, the tab losing focus, the whole canvas unmounting—funnels into one function that ends it exactly once. Releases can become throws. Cancellations never can. Your phone ringing mid-drag should drop the droplet, not launch it.

Liquid merging

Dragging a droplet through other droplets is the whole nostalgia payload, so merging had to feel like liquid and not like Pac-Man.

  • The held droplet always survives. Whatever it touches is the victim. The simulation's normal merge logic just lets whichever droplet gets processed first eat the other one, which is fine for autonomous rain but unacceptable when one of them is attached to your finger.
  • Each victim is consumed exactly once. It gets flagged, contributes its mass, shrinks out, and returns to the pool.
  • Mass accumulates through the drag. Droplets grow toward a target mass over time, so a held droplet mid-growth is a droplet whose true size lives in targetMass, not mass. Each new victim has to stack on top of the pending total or drive-by merges would quietly eat each other:
const mergeBase = droplet.pointerId !== null ? Math.max(droplet.mass, droplet.targetMass) : droplet.mass; droplet.targetMass = Math.min( this.options.maximumMass, mergeBase + this.largeDroplets[i].mass, );

That maximumMass cap is doing quiet aesthetic work: you can bully a droplet into becoming the biggest drop on the glass, but never into a screen-filling blob.

The dirtiest bugs in this phase weren't in merging at all—they were in the object pool. Recycled sprites carry their entire past life with them: old velocity, old merge targets, a stale pointer ID from a drag that ended three lifetimes ago. A freshly spawned droplet that instantly rockets sideways because its previous self was mid-throw is a special kind of haunting. Every reused droplet now passes through one reset() that wipes velocity, throw momentum, merge state, removal flags, and pointer state in one place.

Throwing rain

The release is where it either feels like rain or feels like a physics tutorial. The approach: keep a tiny rolling gesture history and derive momentum from time, never from frames.

While you drag, the droplet records timestamped pointer samples into a bounded window—the last 120 milliseconds, max 20 samples. Garbage is rejected at the door: non-finite values, out-of-order timestamps, duplicate timestamps from coalesced events. On release:

const first = recent[0]; const last = recent[recent.length - 1]; const gestureMs = last.timeMs - first.timeMs; if (gestureMs < options.minGestureMs) return null; let x = (last.x - first.x) / gestureMs; let y = (last.y - first.y) / gestureMs; const speed = Math.sqrt(x * x + y * y); if (speed < options.minThrowSpeedPxPerMs) return null; if (speed > options.maxSpeedPxPerMs) { const scale = options.maxSpeedPxPerMs / speed; x *= scale; y *= scale; }

A few things fall out of that shape almost for free:

  • First-to-last displacement over gesture time doesn't care how many samples landed in between—so a 60Hz mouse and a 120Hz trackpad produce the same throw for the same physical flick.
  • The window is recent. Drag fast, then hold still for half a second before releasing? The window is stale, no throw, the droplet just drops. Exactly like letting go of something.
  • The cap clamps the total vector, so a violent diagonal flick can't smuggle extra speed through its components.
  • Below a minimum speed, a release is just a release. Placing a droplet gently shouldn't nudge it.

The thrown droplet then integrates by real elapsed frame time (the requestAnimationFrame timestamp, clamped so a backgrounded tab can't accumulate a 4-second "frame" and teleport a droplet across the screen) and decays exponentially back to rest—at which point the regular simulation takes over again, and your droplet goes back to being rain. Grab it mid-flight and it stops dead, momentum absorbed by your finger.

Testing rain without a GPU

Sixty-something Jest tests cover this system and not one of them touches WebGL. PIXI is mocked down to sprites-with-coordinates, which is all the interaction logic ever needed anyway: hit eligibility, overlap tie-breaking, ownership, every cancellation path, merge accumulation, the mass cap, pool reset hygiene, velocity derivation, cadence equivalence at 60/120/144Hz, frame-gap clamping. Pure functions are wonderfully boring to test, which is the highest compliment I can pay code.

Browser automation was the fun part, because a WebGL canvas is a black box to a test—you can't query the DOM for a droplet that a shader hallucinated. But remember the grab cursor? It's set via document.body.style.cursor, which means it's observable. So the browser test plays a little game of hot-and-cold:

  1. Sweep synthetic mousemove events across a grid until the cursor flips to grab—congratulations, you found a droplet without being able to see.
  2. pointerdown there and assert the cursor says grabbing.
  3. Drag to a target, release, then hover the drop point—if the cursor says grab again, the droplet actually followed you.
  4. Flick and release, wait 400ms, confirm the release point is cold and somewhere downstream along the throw direction is hot.

The rain can't hide from the cursor. Frame times got the same treatment—sampling requestAnimationFrame deltas while a synthetic drag plowed through the droplet field came back within a couple percent of the idle baseline, which is what four years of object pooling buys you.

Tuning the feel

Every number that decides how the rain feels lives in one options object:

export const defaultThrowOptions = { sampleWindowMs: 120, maxSamples: 20, minGestureMs: 8, maxSpeedPxPerMs: 0.1, minThrowSpeedPxPerMs: 0.04, decayTimeConstantMs: 100, restSpeedPxPerMs: 0.02, maxFrameDeltaMs: 50, };

I retuned these constantly once the mechanics worked—the first cap felt like flicking marbles, the current one feels like water with somewhere to be—and after the second time a tuning pass broke tests that had old numbers baked in, the tests were rewritten to read their expectations from the options object itself. Tune freely; the suite doesn't care.

That's the whole trick, really. No new engine, no dependency upgrades, no rewrite—the same PIXI 4 shader demo that's been running here for years, plus hit testing, ownership rules, merge bookkeeping, and about 120 milliseconds of memory.

Go throw some rain around.