A moving spotlight can reveal a different landscape without moving either image. Stack two matching views, apply a radial mask to the upper one, and update its position with JavaScript.
In our experiment, a grey wireframe terrain sits underneath a green landscape. The reveal is a change in visibility, rather than a 3D lighting simulation.
1. Start with two matching layers
Use images with the same framing. Their crop and background position need to match, otherwise the terrain shifts at the edge of the spotlight. The example below uses two images generated for this demo.
<div class="scene" aria-hidden="true"> <div class="terrain base"></div> <div class="terrain reveal"></div> </div> The scene is decorative. Keep headings, explanations and controls outside it so they remain readable and accessible.
.scene { --sx: 64%; --sy: 42%; --radius: clamp(190px, 27vw, 400px); position: relative; aspect-ratio: 16 / 9; overflow: hidden; } .terrain { position: absolute; inset: 0; background-size: cover; background-position: center; pointer-events: none; } .base { background-image: url("https://systra-spotlight.pages.dev/heros/spotlight-terrain-wireframe.webp"); } .reveal { background-image: url("https://systra-spotlight.pages.dev/heros/spotlight-terrain-bloom.webp"); --mask: radial-gradient( circle var(--radius) at var(--sx) var(--sy), #000 0%, #000 32%, rgb(0 0 0 / 72%) 52%, rgb(0 0 0 / 36%) 70%, rgb(0 0 0 / 10%) 86%, transparent 100% ); mask-image: var(--mask); -webkit-mask-image: var(--mask); } 2. Make the edge feel like light
An opaque mask area shows the upper layer; a transparent area hides it. The intermediate alpha stops create a gradual falloff. With a hard edge, the same setup looks more like a circular cutout.
The black color is not painted over the landscape. Its alpha determines how much of the masked image remains visible.
3. Position the mask in the scene's coordinates
Pointer coordinates are relative to the viewport. Subtract the scene's bounding rectangle, then normalize the result. Percentages also keep the mask position meaningful when the layout changes.
This is the minimal direct-follow version. Run it after the scene exists:
const scene = document.querySelector('.scene'); const reduce = matchMedia('(prefers-reduced-motion: reduce)'); const fine = matchMedia('(hover: hover) and (pointer: fine)'); scene.addEventListener('pointermove', event => { if (reduce.matches || !fine.matches) return; const rect = scene.getBoundingClientRect(); const x = (event.clientX - rect.left) / rect.width * 100; const y = (event.clientY - rect.top) / rect.height * 100; scene.style.setProperty('--sx', `${x}%`); scene.style.setProperty('--sy', `${y}%`); }); This is CSS masking with JavaScript positioning. Calling it a pure CSS interaction would leave out an important part of the implementation.
4. Separate the target from the current position
For inertia, let pointer events update a target and let an animation loop move the current position towards it:
const ease = 1 - Math.exp(-Math.min(deltaMs, 64) / 210); current.x += (target.x - current.x) * ease; current.y += (target.y - current.y) * ease; Here, deltaMs is the elapsed time between frames. The exponential coefficient accounts for elapsed time rather than applying the same step on every frame. The 64 ms cap limits the jump after a long frame. Adjust the 210 ms time constant to change how heavy the light feels.
Store these values outside React state when they only drive the visual effect. The complete React version uses refs and writes CSS custom properties, so pointer movement does not need to trigger a React render on every frame.
5. Give people a quiet version
Keep a visible static spotlight as the baseline. Respect prefers-reduced-motion, provide a still-view control, and avoid taking over touch scrolling. A labelled range input offers explicit positioning with a keyboard or a touch screen.
If you add continuous movement, pause animation work when the scene is outside the viewport or the document is hidden. These guards reduce unnecessary updates; they do not establish a particular frame rate or make the mask free to render. Test the image sizes and effect on your target devices.
Full example and source
The live demo includes the React component, both image assets and design notes. Its download retains the usage terms linked on that page. The small standalone CodePen adaptation is a separate implementation.
Technical references: CSS mask-image, getBoundingClientRect and prefers-reduced-motion.
Disclosure: I co-run Systra Tools, where the original component comes from. This article and the standalone adaptation were prepared with AI assistance. The example imagery is AI-generated; the survey shown in the full demo is fictional.


