A sentence on a leash
I found haha.services and spent longer than I would like to admit dragging my cursor around it. The page is a block of copy. Dragging pulls the sentence out of the paragraph a letter at a time and strings it along your path, where it stays.
There's a canvas library underneath it, but I don't think the idea needs one. It's a queue.
One slot per letter
Make an array of points, one for every character in the sentence. Character 0 reads point 0, character 1 reads point 1, and so on. New points go on the front of the queue near the cursor, and the oldest point falls off the back.
The part I did not expect is that the queue does not start empty. It starts full of the resting paragraph: every position in the block is already a slot. So the first drag point puts the last letter under your cursor and shunts every other character one place down the block, which is why the paragraph visibly reflows while its tail peels off. Keep dragging and the whole thing unspools into a line.
const CHARS = [...'DRAG TO PULL THIS SENTENCE OUT OF THE BLOCK. ']
// One slot per character. The queue starts as the resting BLOCK layout,
// not empty. That is the whole trick.
const trail = layoutBlock(CHARS)
function push(x, y) {
trail.push({ x, y }) // new point at the head, under the cursor
trail.shift() // oldest falls off, every letter shuffles down one
}
function draw() {
glyphs.forEach((el, i) => {
el.style.transform = `translate(${trail[i].x}px, ${trail[i].y}px)`
})
}That was enough to get it on screen. Here it is with the two dials that ended up mattering.
Where I got the spacing wrong
My first version pushed a point on every pointermove, so the letters ended up spaced by however far the cursor happened to travel between two events. Moving slowly piled them on top of each other. Moving fast tore the sentence apart. The original fixes half of that by ignoring any move shorter than the letter spacing, which stops the bunching. The other half stayed broken for me. When the pointer outruns the event rate, every letter lands exactly as far apart as the pointer jumped.
What worked was to stop treating a pointer event as a point. I treat it as a line segment now, walk along it, and drop a letter every N pixels. Whatever distance is left over carries into the next event.
function advance(x, y, spacing) {
let head = trail[trail.length - 1]
let dx = x - head.x
let dy = y - head.y
let dist = Math.hypot(dx, dy)
// A teleport (pointer re-entry, tab-in) shouldn't emit thousands of
// points. Filling the queue is the most one move can ever justify.
let budget = trail.length
while (dist >= spacing && budget-- > 0) {
const step = spacing / dist
head = { x: head.x + dx * step, y: head.y + dy * step }
trail.push(head)
trail.shift()
dx = x - head.x
dy = y - head.y
dist = Math.hypot(dx, dy)
}
}That way the spacing belongs to the sentence and stops depending on the input device. Both lanes below get the same path at the same sample rate, and the only difference is what they do with it.
I dropped the demo to twenty hertz because it isn't far off real conditions. Coalesced pointer events, a busy main thread, a cheap Android, one long frame during hydration. Any of them will hand over a pointer that jumped further than expected.
The lag is a second queue
The whip comes from not drawing the queue I'd just built. I keep a second array that chases the first, moving each point a fraction of the way toward its counterpart every frame. Because the whole queue shifts one place every time a point is added, each drawn point is chasing a target that's itself sliding down the queue, so the tail always arrives late.
const drawn = trail.map((p) => ({ ...p }))
// tau is a time constant in seconds: after tau, roughly 63% of the gap has
// closed. Deriving k from elapsed time rather than dividing by a constant
// each frame keeps the lag identical at 60Hz and 120Hz.
function ease(dt, tau) {
const k = 1 - Math.exp(-dt / tau)
for (let i = 0; i < drawn.length; i++) {
drawn[i].x += (trail[i].x - drawn[i].x) * k
drawn[i].y += (trail[i].y - drawn[i].y) * k
}
}The original divides the remaining distance by a constant every frame. It's the classic way to write it, and it means the effect settles measurably faster on a 120Hz display than a 60Hz one. I derived the factor from elapsed time instead, which costs one exp() per frame and turns the lag into a duration I can set in milliseconds.
What I changed
No colour inversion. Dragging the original also maps cursor x onto the text lightness and cursor y onto the background, so the whole page inverts under your hand. That is lovely on a site which is only this one thing. Inside an article it would fight everything around it, so I left the palette alone.
Rotation is optional. Every glyph stays upright on the original, so you read the sentence by hopping between letters rather than along a line. Rotating each one to the local tangent, taken from the two points either side of it, makes it read as text again. The cost is that doubling back flips the letters upside down, so it is a toggle and upright is the default.
A shorter string. The original runs 130 characters at 30px tracking across a full viewport, roughly 2.6 screen-diagonals of trail. Keeping that count inside a 680px column just folds the line into an illegible knot, so the sentence is shorter and the ratio comes out about the same.
It waits for a box. Load the original in a background tab and it initialises at zero width, takes its small-screen branch, and stacks all 130 glyphs on a single point. Its resize handler only fixes the background rectangle, so it never recovers. Worth knowing about, because a broken render like that looks convincing enough to reverse-engineer from. This one waits for a real size before laying anything out.
It parks. Nothing moves once the letters have arrived, so the frame loop stops scheduling itself when the largest remaining gap drops below a twentieth of a pixel, and wakes on the next drag. A demo sitting halfway down an article should not be burning frames to hold still.
It ended up being three functions and about forty lines with no dependencies. Most of my time went on the spacing.
Posted on 26 July 2026
Permalink, All Playground