A sentence on a leash
I found haha.services and spent way longer than I'd like to admit dragging my cursor around it. The page is just a block of text, and when you drag, the sentence pulls out of the paragraph a letter at a time and follows your path, and then it stays there.
There's a canvas library underneath it, but I don't think you really need one for this. It's basically just a queue.
The queue
You 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 one falls off the back.
The queue doesn't start empty. It starts full of the paragraph as it sits on the page, so every position in the block is already a slot. That means the first drag puts the last letter under your cursor and shifts every other character one place along, which is why you can see the paragraph reflow while the last letters come away from it. If you keep dragging, the whole paragraph ends up as one 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 two settings you can change.
Spacing
My first version added a point on every pointermove, so the letters ended up spaced by however far the cursor happened to move between two events. Moving slowly piled them up on top of each other and moving fast spread them too far apart. The original fixes half of that by ignoring any movement shorter than the letter spacing, which stops them bunching up. The other half was still broken for me though. When the pointer moves faster than the events come in, the letters land exactly as far apart as the pointer jumped.
What worked was treating each pointer event as a line. I walk along that line and drop a letter every N pixels, and whatever distance is left over carries on 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 stays the same whatever device you're using. Both lanes below get the same path at the same sample rate, and the only difference is what they do with it.
I set the demo to twenty hertz because that isn't far off what happens in real life. A busy main thread or a cheap Android phone can easily give you a pointer that's jumped further than you'd expect.
The lag
The delay in the trail comes from not drawing that queue directly. I keep a second array that follows the first one, moving each point a bit of the way towards its match every frame. Because the whole queue shifts along one place every time a point gets added, each drawn point is moving towards a position that's also moving, so the end of the sentence always lags behind.
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 fixed number every frame. That's the usual way to write it, but it means the effect settles noticeably faster on a 120Hz screen than on a 60Hz one. So I worked the factor out from the time that's passed, which costs one exp() a frame and means I can set the lag in milliseconds.
What I changed
Colour. In the original, dragging also maps the cursor's x position onto the text colour and y onto the background, so the whole page inverts as you move. I think that's lovely on a site that's only that one thing, but inside an article it would clash with everything around it, so I left the colours alone.
Rotation. On the original every letter stays upright, so you read the sentence by jumping between letters. Rotating each one to follow the line, using the points either side of it, makes it read like text again. The downside is that doubling back flips the letters upside down, so I made it a toggle and left upright as the default.
Length. The original has 130 characters spaced 30px apart across the full screen, which makes a trail about 2.6 times the diagonal of the screen. Fitting that many into a 680px column just bunches it up so you can't read it, so I made the sentence shorter to keep it roughly in proportion.
Background tabs. If you load the original in a background tab, it starts up at zero width, uses its small-screen layout, and stacks all 130 letters on one point. Its resize handler only fixes the background, so it stays broken. I mention it because a broken render like that looks convincing enough that you could end up copying it by mistake. Mine doesn't lay anything out until the box has a real size.
Stopping. The animation loop stops once the biggest remaining gap is under a twentieth of a pixel, and starts again on the next drag. I didn't want a demo halfway down an article using up frames when nothing's moving.
In the end it was three functions and about forty lines with no dependencies.
Posted on 26 July 2026
Permalink, All Playground