Every large title on this site is drawn as halftone dots. They fly in and assemble after the preloader, push away from the cursor and scatter as you scroll. It looks like an image effect, but the text is real: you can select it, search it, and a screen reader reads it like any other heading. This note covers how that works and what it costs.
Keep the text, paint over it
The component, DotText, renders the heading as normal HTML and lays a canvas over it. Once the WebGL scene is ready the element gets an is-live class, which makes the glyphs transparent while keeping them in the DOM. If WebGL is missing, the context is lost or the page runs in forced-colours mode, the canvas is hidden and an SVG filter draws a static halftone instead. Without JavaScript you get that static version too.
That split decides most of the rest: layout, wrapping and font loading all belong to CSS, and the canvas only has to copy what the browser already laid out.
Sampling the glyphs the browser drew
Rendering the string again with fillText would wrap and kern differently from the DOM. So the sampler walks the element’s text nodes and uses a Range to read each character’s client rect. Each glyph is drawn on an offscreen 2D canvas at exactly that position, with a horizontal scale that matches the DOM advance in case the canvas picks a slightly different width.
The display face is a variable font set to a condensed width, and canvas fonts don’t take font-variation-settings. The closest you get is ctx.fontStretch, so the computed font-stretch percentage is mapped to the nearest keyword (extra-condensed for this face).
From coverage to dot sizes
The alpha channel of that canvas is the ink coverage. Two box-blur passes per axis approximate a gaussian, so coverage falls off smoothly at the edge of each stroke. A grid is then laid over the result, and each cell becomes one dot whose radius follows the blurred coverage under it:
const r = (pitch / 2) * Math.min(0.92, v * 0.8 - 0.08)
if (r < 0.35) continue // too small to see; skip the point entirely
home.push(x - pad, y - pad)
rs.push(r)
Dots are full inside a stroke and shrink towards its edge, the same screen the SVG fallback produces, so the two versions match. The grid pitch is 6px on desktop and 4px below 720px, where type is smaller and needs a finer screen to stay readable.
All the motion lives in the vertex shader
Each dot gets its home position, radius and four random numbers as attributes, uploaded once. Everything that moves is a uniform: intro progress, scroll scatter, time and the cursor position. The vertex shader works out each dot’s position from those, so a frame is a handful of uniform* calls and one drawArrays(gl.POINTS), whether the title has 2,000 dots or 20,000.
// Intro: each dot flies in from its own offset with its own delay
float t = clamp(uProgress * 1.8 - aRand.w * 0.8, 0.0, 1.0);
float e = 1.0 - pow(1.0 - t, 3.0);
vec2 pos = uOrigin + aHome + aRand.xy * uSpread * (0.4 + aRand.w) * (1.0 - e);
// Cursor repulsion
vec2 d = pos - uMouse;
float len = length(d);
float f = (1.0 - smoothstep(0.0, uRadius, len)) * uMouseStrength;
pos += d / max(len, 0.001) * f * uPush;
The fragment shader draws an anti-aliased disc from gl_PointCoord and blends towards the accent colour by how hard the cursor is pushing that dot. Colour is read from the element’s computed color, so theme switches and hover transitions carry over without extra code.
Building lazily
Sampling is the expensive part: reading pixels back and blurring them is main-thread work. The first version built every title on load, including the footer, and it showed up as long tasks. Now an IntersectionObserver with a half-viewport margin decides when a title is near enough to sample, and the ticker skips titles that are off-screen or in a hidden tab. A ResizeObserver and document.fonts.ready mark the scene dirty, and it rebuilds on the next frame only if it is near the viewport.
Under prefers-reduced-motion the dots are drawn once, at rest, and repainted only when the colour changes. No ticker runs at all.
What I would keep
- Leave the text in the DOM. The effect is decoration on top of a heading, and it never replaces one.
- Sample what the browser rendered instead of re-implementing text layout.
- Put the per-frame work in a shader and keep the CPU side to uniforms.
- Do the costly setup only when the element is about to be seen.
Originally published on my site. I'm Muhammad Tanveer, a full-stack developer in Faisalabad, Pakistan: muhammad-tanveer-dev.vercel.app.
Top comments (0)