The core idea
Traditional CSS text layout is a black box — the browser decides where lines break and you adapt around it. A canvas-based text layout engine inverts this: you measure text yourself, then decide exactly where each line breaks based on whatever geometry you want. This enables text that flows around moving objects in real time.
Text measurement
Before any layout can happen, the text is measured upfront using a font-aware library. This builds an internal map of every character's width for a given font. This measurement step is expensive and only done once — the result is a prepared text object that can be queried repeatedly at negligible cost.
From this prepared object you can ask: "given a cursor position and a maximum width, how much text fits on one line?" — and get back the text content, its rendered width, and where the cursor ends up. This is the primitive everything else is built on.
const prepared = prepareWithSegments(text, font)
const line = layoutNextLine(prepared, cursor, maxWidth)
// → { text, width, end: nextCursor }The layout loop
Text is laid out one horizontal band at a time, top to bottom. A band is a strip of space exactly one line-height tall at a specific vertical position. For each band:
- Ask every obstacle: "what horizontal range do you block at this height?" → returns an interval
{ left, right } - Carve those blocked intervals out of the full available width → remaining usable ranges are slots
- Fill each slot left to right with as much text as fits
- Advance down one line height and repeat
The result is a flat array of positioned lines — each with an x, y, width, and text content — ready to be written to the DOM.
Obstacles
Two types of obstacles are supported, each requiring different math to compute their blocked interval per band.
Circle obstacles — their footprint on a band varies by height. Wide in the middle, narrow near the top and bottom. The blocked width at a given band is computed using the circle equation: find the minimum vertical distance from the circle center to the band, then use Pythagoras to get the horizontal extent.
const minDy = /* distance from circle center to band */
const maxDx = Math.sqrt(r * r - minDy * minDy)
// blocked = { left: cx - maxDx - pad, right: cx + maxDx + pad }Rect obstacles — fixed rectangles that block the same width on every band they cover. No math needed beyond a simple overlap check.
Moving objects
Because obstacles are rebuilt fresh every frame from the object's current position, text automatically reflows around anything that moves. The layout engine doesn't care how an object got to its position — it only sees the current x, y, and radius.
The dragon above follows your cursor using a simple step each frame: compute the vector to the target, normalize it to get direction, multiply by speed × dt. Normalization — dividing both components by the distance — separates direction from magnitude, allowing speed to be controlled independently.
const dist = Math.sqrt(dx * dx + dy * dy)
const step = Math.min(speed * dt, dist - stopDist)
dragon.x += (dx / dist) * step
dragon.y += (dy / dist) * stepThe animation loop
No setInterval or while loop. The loop is a self-scheduling chain where each frame only re-queues the next frame if something is still moving. When the dragon is idle, the loop goes idle and stops consuming CPU entirely. Any external event — pointer move, resize — restarts it by queuing one frame.
DOM output
Layout produces data, not DOM. The DOM write is a separate final step each frame. Element pooling avoids creating and destroying DOM nodes every frame — a fixed pool of elements is maintained, grown when needed, with extras hidden. Each frame just updates text content and position on existing elements, which is significantly faster than creating new nodes.
Why this approach
The old approach is CSS layout. The browser handles everything — line breaking, wrapping, flow. You write some markup, apply styles, and the engine figures out where text goes.
The downside is that CSS layout runs once and is fixed relative to the document flow. You can float an image and text will wrap around it — but only rectangles, only static elements, and only within normal document flow. There is no way to make text reflow around a circle, around an element that moves, or around arbitrary geometry. The browser gives you no hook into the line-breaking process. And if you try to animate it by updating the DOM every frame, you destroy and reconstruct nodes on each tick — which is expensive and causes visible jank.
When text changes position — say, an obstacle moves — the browser needs to recalculate where every line breaks. This process is called reflow. The browser measures each element's size and position, resolves how content wraps, then repaints the screen. Reflow is triggered any time the DOM structure or geometry changes. For animated layout, the naive solution is to tear out the old DOM nodes and insert new ones with updated positions. But this forces a full reflow on every frame: the browser discards its cached layout, remeasures everything from scratch, and repaints. At 60fps, that cost adds up fast.
This approach takes ownership of the entire pipeline — measurement, line breaking, obstacle math, DOM writes — and runs it every frame. The cost is complexity. The benefit is that you can do things CSS simply cannot: text flowing around a curve, around overlapping shapes, around objects that move in real time. Any geometry becomes a valid obstacle. Any moving object can push text out of the way.
Pitfall: stage is possibly null
When using useRef in React, the ref is typed as HTMLDivElement | nullbecause the element doesn't exist yet at the time the component function runs — it only gets attached after the first render. TypeScript sees the ref as potentially null everywhere, including inside nested functions like syncPool, even if you've already checked it at the top of the effect.
The cause is that TypeScript's null narrowing doesn't carry into closures. When you write if (!stage) return, TypeScript narrows stageto non-null for the rest of that block — but inside a nested function defined later, it can't guarantee the narrowing still holds, since the function could be called at any time.
A common but unsafe fix is the non-null assertion operator ! — writing stage!.appendChild(el). This simply tells TypeScript to stop complaining without any actual check. If stage were ever null at runtime, it would throw — silently bypassing the type system is never the right answer.
The safe fix is narrowing by assignment. After the null check, assign stage to a new variable typed as HTMLDivElement — not nullable. TypeScript infers from the assignment that this variable can never be null, and that narrowing carries into any closure that uses it:
const stage = stageRef.current
if (!stage) return
const container: HTMLDivElement = stage // narrowed — TypeScript knows this is non-null
function syncPool(...) {
container.appendChild(el) // ✓ safe in closures
}
const pageWidth = container.clientWidth // ✓
container.style.cursor = 'grabbing' // ✓Unlike !, this approach doesn't bypass the type system — it works with it. TypeScript understands that containerwas assigned from a non-null value and will enforce that contract everywhere it's used.