Introduction
This piece reads as a cluster of code characters with a few loose threads hanging off it, swaying and swinging when you touch them — but underneath, it is one array of characters split two ways: most of them are stamped onto a grid wherever a hidden canvas shape says 'filled,' and the rest are handed one at a time to independent rope simulations. Nothing here uses a physics library. Each thread is a short chain of points updated with Verlet integration, and the only shared state across all five threads is which single point, on which single thread, the pointer currently has hold of.
The illusion depends on three things staying decoupled: the silhouette is computed once, up front, on an offscreen canvas that is never drawn to the page — it only answers 'is this pixel inside the shape?' for each grid cell. The rope threads never know about the silhouette at all; they just start at an anchor point on its edge and fall under gravity. And the drag logic never touches physics directly — it only ever relocates one point per frame and lets the constraint solver figure out what the rest of the thread should do about it.
How to fill an arbitrary shape with characters by sampling a hidden canvas at grid points instead of hand-placing text — how Verlet integration lets a chain of points fall and swing without ever storing a velocity — how iterative distance constraints keep a rope's segment lengths honest after gravity or a drag has pulled them apart — how to hit-test a pointer against many independent chains at once and pin exactly one point while it's held — and how a single prefers-reduced-motion query can freeze a physics simulation into a readable static pose.
Step 1 — Asking a Hidden Canvas Which Cells Are Inside
The blob shape is never drawn as an SVG path or a background image — it is drawn once onto an offscreen <canvas> that's never appended to the DOM, using two overlapping ellipses. Once that canvas has been filled, getImageData() reads back a flat array of pixel alpha values, and insideMask(x, y) becomes a single lookup: round the coordinate, find its index in that array, and check whether the alpha channel is above a threshold. Everything downstream treats that function as a boolean shape test and never touches the canvas again.
const maskCanvas = document.createElement('canvas');
maskCanvas.width = W; maskCanvas.height = H;
const mctx = maskCanvas.getContext('2d');
mctx.fillStyle = '#000';
mctx.beginPath();
mctx.ellipse(cx, cy, R, R * 0.9, 0, 0, Math.PI * 2);
mctx.fill();
mctx.beginPath();
mctx.ellipse(cx - R * 0.25, cy + R * 0.55, R * 0.6, R * 0.4, 0.3, 0, Math.PI * 2);
mctx.fill();
const maskData = mctx.getImageData(0, 0, W, H).data;
function insideMask(x, y) {
x = Math.round(x); y = Math.round(y);
if (x < 0 || y < 0 || x >= W || y >= H) return false;
const idx = (y * W + x) * 4 + 3; // alpha channel
return maskData[idx] > 100;
}Filling the shape is then a plain nested loop over the silhouette's bounding box, spaced by the monospace character's width and line height. Every cell that passes insideMask() gets the next character pulled from a shared, stripped-of-whitespace source string, plus a small seeded jitter on its position and a small baked-in rotation, so the block reads as hand-set type rather than a perfect grid.
for (let gy = cy - R * 1.05; gy < cy + R * 1.05; gy += LINE_H) {
for (let gx = cx - R * 1.1; gx < cx + R * 1.1; gx += CHAR_W) {
if (!insideMask(gx, gy)) continue;
const jitterX = (rand() - 0.5) * 1.4;
const jitterY = (rand() - 0.5) * 1.4;
const span = document.createElement('span');
span.textContent = nextChar();
span.style.left = (gx + jitterX) + 'px';
span.style.top = (gy + jitterY) + 'px';
stage.appendChild(span);
blobChars.push({ el: span, hx: gx + jitterX, hy: gy + jitterY, baseRot: (rand() - 0.5) * 10 });
}
}Step 2 — Verlet Integration: Position Remembers Velocity
Each thread is an array of nodes, and every node stores only two positions — its current x/y and its previous x/y from last frame — instead of a separate velocity field. Velocity is recovered each frame as the difference between the two, scaled by a damping factor, and then applied forward along with gravity. This is standard Verlet integration: it is numerically stable under the iterative corrections a rope needs, and it means a drag can simply overwrite a node's position without having to also compute or fake a matching velocity.
for (let i = 1; i < nodes.length; i++) {
const n = nodes[i];
const vx = (n.x - n.oldx) * DAMPING;
const vy = (n.y - n.oldy) * DAMPING;
n.oldx = n.x; n.oldy = n.y;
n.x += vx;
n.y += vy + GRAVITY;
}A Verlet node never asks 'how fast am I moving?' — it only ever asks 'where was I last frame?' That single substitution is what lets a drag, a gust of mouse-proximity wind, and gravity all push on the same point without three separate systems needing to agree on a velocity.
Step 3 — Iterative Distance Constraints
After gravity moves every node independently, neighboring nodes are almost never still the correct segment length apart — so a constraint pass runs six times per frame, and each pass nudges every adjacent pair halfway back toward the target distance. Running it multiple times per frame, rather than solving it exactly once, is what makes a whole chain of thirty-plus nodes settle into a convincing rope curve instead of a jittery zig-zag; each pass only removes part of the error, and the errors shrink geometrically across iterations.
for (let iter = 0; iter < ITER; iter++) {
for (let i = 0; i < nodes.length - 1; i++) {
const a = nodes[i], b = nodes[i + 1];
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.hypot(dx, dy) || 0.0001;
const diff = (dist - SEG) / dist;
const offX = dx * 0.5 * diff, offY = dy * 0.5 * diff;
if (!a.pinned) { a.x += offX; a.y += offY; }
if (!b.pinned) { b.x -= offX; b.y -= offY; }
}
nodes[0].x = nodes[0].anchorX; // the anchor is re-pinned every pass
nodes[0].y = nodes[0].anchorY;
}Six constraint passes can drift the very first node by a fraction of a pixel each time gravity and neighboring corrections interact. Snapping node zero back to its exact anchor coordinate at the end of every single pass — not just once per frame — is what keeps thirty nodes of accumulated correction from slowly walking the whole thread's starting point off its silhouette edge.
Step 4 — One Pointer, Many Chains, One Pinned Node
On pointerdown, nearestNode() walks every non-anchor node on every thread and returns whichever one is both closest to the pointer and inside a fixed grab radius. That single (string index, node index) pair becomes the only piece of drag state in the whole system. While it's set, the simulation and constraint loops both special-case that one node — skipping its gravity update and excluding it from constraint correction — and instead just assign it directly to the pointer's position every frame, letting the rest of the chain react to that motion exactly as it would to any other perturbation.
function nearestNode(p) {
let bestS = -1, bestI = -1, bestD = GRAB_R;
for (let s = 0; s < strings.length; s++) {
const nodes = strings[s].nodes;
for (let i = 1; i < nodes.length; i++) {
const d = Math.hypot(nodes[i].x - p.x, nodes[i].y - p.y);
if (d < bestD) { bestD = d; bestS = s; bestI = i; }
}
}
return { s: bestS, i: bestI };
}
function onMove(e) {
const p = localPoint(e);
if (dragIndex > 0) {
const n = strings[dragString].nodes[dragIndex];
n.x = p.x; n.y = p.y; // pinned to the pointer, not the physics loop
}
}Releasing the pointer just resets dragString and dragIndex to -1. Nothing has to explicitly hand the node a throw velocity — because its oldx/oldy from the frames spent following the pointer are still sitting there, the very next gravity update reads a large position delta as velocity automatically, and the thread flies off exactly as fast as the pointer was moving when it let go.
Step 5 — Keeping It Alive at Rest, and Turning It Off Cleanly
Every thread is seeded with its own phase and frequency, and each frame adds a small sine-based 'breeze' force scaled by how far a node sits down the chain — nodes near the anchor barely move, tips sway the most. Because each thread's phase and frequency are drawn from an independent random seed, the five threads never fall into sync, which is what keeps the piece from looking looped even with no pointer on the page at all. A single prefers-reduced-motion check bypasses the animation loop entirely and renders one static frame instead.
const breeze = Math.sin(t * st.freq + st.phase) * st.amp;
n.x += vx + breeze * (i / nodes.length) * 0.06;
n.y += vy + GRAVITY;
// ...
if (reduceMotion) {
renderRopes(); // one static layout pass, no rAF loop
} else {
requestAnimationFrame(frame);
}Tuning Reference
| Property | Example | Effect |
|---|---|---|
| SEG | 13px | Rest length of each rope segment; larger values spread nodes further apart per character |
| GRAVITY | 0.55 | Downward acceleration applied to every unpinned node each frame |
| DAMPING | 0.985 | Fraction of implied velocity kept per frame; lower values settle threads faster |
| ITER | 6 | Constraint-solver passes per frame; more passes yield a stiffer, less stretchy rope |
| GRAB_R | 24px | Pointer hit-test radius used to pick which node a drag latches onto |
| anchorAngles / ropeLens | per thread | Where each thread leaves the silhouette, and how many nodes long it is |
Full Source Code
The complete piece is a single self-contained HTML document — the mask canvas, the silhouette fill loop, five independent rope threads, the shared drag state, and the reduced-motion fallback, and nothing else. No build step, no external assets, no physics library.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/letter-strings.html