Making text legible over a WebGL scene without scrims
- Design
- WebGL
- Typography
Dark plates behind paragraphs always look like dark plates behind paragraphs. Four techniques that solve the same problem without a single visible patch.
Putting body copy over a moving 3D scene is the hard problem in this layout. The object is full-bleed and lit; the text is small and needs to be read. The first version solved it with per-block scrims — semi-transparent dark rectangles behind each text column.
They looked exactly like what they were. A visible plate, obviously added to fix a problem, announcing that the design could not otherwise cope. The rule that came out of that: whatever solves this must not be locatable as a patch.
1. Separation by side
The cheapest contrast is empty space. Each beat parks the object on the opposite side of the viewport from its text column — hero and mission put the object right with text left, the philosophy beat flips it, the work list pushes the object far back and high so the rail can run full width.
This is a camera keyframe and a Tailwind margin agreeing with each other, and it does more work than the other three techniques combined. It also has a maintenance cost worth stating plainly: change one without the other and the text lands on the object.
2. One page-wide atmosphere
A single soft gradient across the entire viewport, biased toward whichever side is currently doing the reading. Because it spans the whole screen, it has no edge anywhere near the text — so the eye reads it as depth in the scene rather than as a surface laid on top.
An edge is what makes a scrim look like a scrim. Remove every edge from the reader's view and the same darkening reads as atmosphere.
3. Scroll-driven pull-back
The shells dim themselves during prose-heavy beats — up to 72% — and return to full brightness when the object is the subject. It is computed in the shader from the same section bounds that drive the geometry.
// components/three/layer-material.ts
// uDim ramps 0..0.72 across prose beats, 0 where the object leads
vec3 lit = mix(shellColour, vec3(0.0), uDim);Crucially this is continuous, not a toggle. A discrete change would be perceived as the page reacting to you; a ramp over several hundred pixels of scroll is not consciously noticed at all.
4. Type that carries itself
Body copy is set at weight 450 rather than 400. Against bloom, a 400 weight goes visually translucent — the glow eats the thin strokes. Fifty units of weight costs nothing and removes the problem at its source.
Related: bloom intensity is held at 0.7 with a 0.3 threshold, well below what looks impressive in isolation. Glow spreading past the geometry is precisely what makes nearby text go soft, so the post-processing is tuned against the typography rather than against the render.
What this costs
- Content columns and camera keyframes are now coupled. Moving one requires moving the other, and there is a comment at both ends saying so.
- Section heights live in CSS and are mirrored in a constant. They must move together.
- The design cannot be evaluated from a screenshot — every one of these is a function of scroll position.
All three are real costs and all three were worth paying, because the alternative is four grey rectangles that everyone can see.