A grid of letters that breathes
Behind the big statement in the middle of my portfolio there is a faint grid of letters, one every 34 pixels. It is meant to be wallpaper. But move the pointer through it and the letters get out of your way, tumble a little, and wobble back into line once you've passed. Click and a ripple runs through the whole grid.
It was dots
In August, in a draft I called Minimal v2, this was a grid of dots: 1.1px circles, 34px apart, at 14% opacity. Within 180px of the pointer each dot was pushed outwards by up to 15px, and every frame it moved 12% of the way towards wherever it was meant to be. Pick Dots, August above to try it.
It is pleasant enough, and it has no weight at all. The dots know where they should be and slide there. Nothing overshoots, nothing carries on moving after you stop, and a fast swipe feels the same as a slow one.
Springs
In October the dots became letters and the easing became physics. Each letter has a home on the grid, an offset from it, a velocity, an angle and a spin. Every frame it is pulled home by a spring and slowed by drag:
const K = 60, DRAG = 5.4, SPIN_K = 90, SPIN_DRAG = 6;
d.vx -= K * d.ox * dt; d.vy -= K * d.oy * dt; // spring back to the grid
d.vx *= Math.exp(-DRAG * dt); d.vy *= Math.exp(-DRAG * dt);
d.vw += -SPIN_K * d.w * dt; d.vw *= Math.exp(-SPIN_DRAG * dt);
d.ox += d.vx * dt; d.oy += d.vy * dt; d.w += d.vw * dt;
A stiffness of 60 with a drag of 5.4 is underdamped: a letter shoved off its spot swings back, overshoots a little, and settles after a wobble or two. That small overshoot is most of why it feels alive. The spin has its own, stiffer spring, so letters right themselves a bit faster than they get home.
The pointer isn't a simple radius either. It is treated as a moving body with its own smoothed velocity, and within 95px it does three things: a bow wave that pushes letters outwards (harder the faster you move), a wake that drags them along in your direction, and a torque that sets them spinning, clockwise or anticlockwise depending on which side of you they were. Each letter also has a random mass, from 0.6 to 1.2, so neighbours never move in lockstep.
Letters that are away from home stay a little bolder and darker until they're back, so you can see the trail you left.
Waves and decoding
On 18 October the grid moved onto the GPU, alongside the letter cloud in the hero. The physics is the same, but each letter's state now lives in a pixel of a floating-point texture that a shader updates every frame. Two things changed on the way:
- The click became a wave train. On the CPU a click sent a single ring outwards at 640px a second. On the GPU it is a travelling sine wave, 170px wide, that alternately pushes letters out and pulls them in as it passes, at 620px a second, fading over 1.4 seconds. Up to four can overlap. Click a few times in different places and they interfere.
- Letters decode. While the pointer is near, a letter cycles through the alphabet about eight times a second, then settles back on its own letter when you leave. It reads like the grid is trying to work out what you're after.
The CPU version is still the fallback on the portfolio when WebGL2 isn't available, so you can compare the two above. It doesn't decode, and its click is the single ring. On the portfolio neither runs if you've asked for reduced motion. Here, with reduced motion on, you get one still frame of the CPU grid a moment after a wave has gone through it.
The library only listens for mouse events, so on this page touch drags are passed through to it as mouse moves.
- Built
- August 2026, reworked October 2026
- Stack
- WebGL2 with a Canvas 2D fallback
- Lives at
- thomasalex.in/portfolio
- Source
- letters-gl.js and
setupDots()