Introduction
This one has no visible geometry at all — just a flat plane, a single rectangular light, and a page of text. The plane sits behind the page, dark everywhere by default, and a small rect area light chases the cursor across it. The trick is what happens to the light’s output afterward: instead of drawing that lit plane as an image, the render pipeline turns its brightness into the transparency of the canvas itself, so the canvas becomes a mask sitting over the words — opaque and hiding them where it’s dark, and transparent and revealing them wherever the lamp lands.
Nothing here is a shader trick layered on top of text — the text is ordinary HTML the whole time. The canvas is the only thing that moves, and it moves by controlling how much of the page beneath it gets let through.
Why a RectAreaLight, not a point or spot light, gives a lamp its rectangular falloff — how a raycast against an invisible plane turns 2D pointer coordinates into a 3D light position — how a render pipeline’s output alpha, not its color, can be used to mask real DOM content — and why the order you attach the canvas to the page matters as much as any of the shader math.
Step 1 — One Rect Area Light, No Ambient
The scene is close to the simplest one WebGPU can render: a single 100×100 plane facing the camera, a physical material on it with no color or roughness overrides, and one RectAreaLight. There is no ambient light and no second light, which matters — with nothing else contributing, any part of the plane the lamp doesn’t reach renders pure black by construction, not by a darkening pass added afterward.
const pageSurfaceMaterial = new THREE.MeshPhysicalNodeMaterial({})
const pageSurface = new THREE.Mesh(new THREE.PlaneGeometry(100, 100), pageSurfaceMaterial)
scene.add(pageSurface)
const READING_LIGHT_SPAN = 0.777
const readingLight = new THREE.RectAreaLight('white', 0, READING_LIGHT_SPAN, 0)
scene.add(readingLight)
scene.add(new RectAreaLightHelper(readingLight))A RectAreaLight is used instead of a point or spot light specifically for its shape: it has a literal width and height, so the lit patch on the plane comes out as a soft rectangle rather than a circle, and that rectangle can be stretched independently on each axis later as the lamp moves. It starts at zero intensity, since Part 2 is what actually turns it on.
Step 2 — From Screen Coordinates to a Point on the Plane
Every pointermove event gets converted into normalized device coordinates and fired through a raycaster at the plane. Wherever that ray actually lands is turned into a bearing and a distance from the plane’s center, and the light gets repositioned along that bearing, slightly past the hit point, always facing back toward the origin.
function sweepReadingLight(nx, ny) { // nx, ny in [-1, 1]
raycaster.setFromCamera(new THREE.Vector2(nx, ny), camera)
const intersections = raycaster.intersectObject(pageSurface)
for (const hit of intersections) {
if (hit.object !== pageSurface) continue
const bearing = -Math.atan2(hit.point.y, hit.point.x) + Math.PI / 2
const throwDistance = Math.max(READING_LIGHT_MIN_RADIUS, Math.hypot(hit.point.x, hit.point.y)) + 0.2
readingLight.position.set(
throwDistance * Math.sin(bearing),
throwDistance * Math.cos(bearing),
readingLight.width / 2
)
readingLight.lookAt(0, 0, readingLight.width / 2)
readingLight.height = Math.min(READING_LIGHT_MAX_HEIGHT, Math.max(READING_LIGHT_MIN_HEIGHT, Math.hypot(nx, ny)))
readingLight.intensity = READING_LIGHT_MIN_INTENSITY + Math.min(READING_LIGHT_MAX_INTENSITY - READING_LIGHT_MIN_INTENSITY, Math.hypot(nx, ny))
}
}The lamp’s height and intensity are both driven by the same number — how far the pointer sits from the screen’s center — so a cursor near the edges naturally reads as a bigger, brighter throw than one near the middle.
Step 3 — Writing Darkness Into the Alpha Channel
A normal render just outputs color. Here, the render pipeline’s output node keeps the lit plane’s color in the RGB channels but replaces alpha entirely: it takes the plane’s brightness, inverts it, and pushes it through a steep power curve, so anything close to fully lit collapses toward zero alpha and everything else climbs toward full opacity.
const scenePass = T.pass(scene, camera)
renderPipeline.outputNode = T.vec4(
scenePass.rgb,
T.mx_rgbtohsv(scenePass.rgb).z.oneMinus()
.pow(20) // vignette falloff — higher = sharper edge between lit and dark
.mul(0.98) // vignette strength, 0..1 — higher = darker unlit page
)Writing brightness into color would only ever darken the canvas itself. Writing it into alpha lets the canvas act as a physical cutout over whatever is stacked beneath it — near the lamp the canvas nearly disappears and the real page shows through directly; everywhere else it sits as close to fully opaque black as the strength value allows.
Step 4 — Falloff, Strength, and a Lens That Shifts With Scroll
Two numbers do almost all the work of making the mask read as a lamp rather than a smear: the power curve’s exponent, which controls how crisp the boundary between lit and dark is, and the multiplier on top of it, which controls how close to fully opaque the darkness ever gets. A gentler exponent and a lower multiplier both were tried first and looked closer to a fog effect than a light; a steep exponent paired with a multiplier near 1 is what makes the edge of the beam feel like an actual boundary.
function tuneReadingLightColor() {
const s = getComputedStyle(renderer.domElement)
const progress = Math.min(1, scrollY / (document.body.scrollHeight - parseFloat(s.height)))
readingLight.color.setHSL(progress, 0.5, 0.5)
}
addEventListener('scroll', () => tuneReadingLightColor())Scroll position feeds the lamp’s hue on top of all this, so the light itself drifts around the color wheel as you move down the page — a small touch, but it keeps a static effect from feeling static on a page long enough to scroll.
Step 5 — Paint Order Is the Whole Effect
None of the masking math matters if the canvas paints underneath the text — the words would just sit on top of it, always fully readable. The canvas has to be the last element attached to the page, not the first, so it paints after (and therefore over) everything else, with pointer-events disabled so it never actually blocks clicks or scrolling on the content it’s covering.
renderer.setAnimationLoop(() => renderPipeline.render())
document.body.appendChild(renderer.domElement) // on top of the text, so its alpha masks itTuning Reference
| Property | Example | Effect |
|---|---|---|
| READING_LIGHT_SPAN | 0.777 | Fixed width of the lamp’s rectangle; height varies, width doesn’t |
| READING_LIGHT_MIN_HEIGHT / MAX_HEIGHT | 0.3 / 0.5 | How much the lamp rectangle stretches as the pointer moves further from center |
| READING_LIGHT_MIN_INTENSITY / MAX_INTENSITY | 1.5 / 3.5 | Brightness range, also driven by pointer distance from center |
| vignette exponent (pow) | 20 | Sharpness of the edge between the lit reveal and the dark mask |
| vignette strength (mul) | 0.98 | How close to fully opaque black the mask gets away from the light |
| READING_LIGHT_MIN_RADIUS | 0.3 | Minimum distance the lamp is pushed out from the plane’s center |
Full Source Code
The whole thing is a single self-contained HTML file — the plane, the light, the raycast-driven sweep, and the alpha-mask output node, with Three.js’s WebGPU renderer and TSL pulled in through an import map and no build step.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/reading-light.html