Radial Mask Spotlight
A stacked layer is masked by a radial gradient whose centre follows the pointer, so content is lit rather than uncovered. The mask string is rewritten once per animation frame from a damped pointer position, and both the prefixed and unprefixed `mask-image` properties are set.
Radial Mask Spotlight
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/radial-mask-spotlight.jsonNo runtime dependencies.
Also installs use-raf, use-smooth-pointer from this registry.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
4 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Content of the lit layer. Should have a transparent background. |
| radius | number | = 220 | Beam radius in CSS px. Core is 30% of this, falloff completes at 72%. |
| smoothing | number | = 0.0006 | Fraction of the gap remaining after one second. Lower is tighter. |
| className | string | — | Merged onto the root container via cn. |
How it works
The technique in order, not the code in order.
The gradient stops are the design: solid black to 30% of the radius, then a ramp to transparent at 72%. That flat core gives the beam a lit region with an actual size. A gradient that begins falling off at 0% has no lit area at all — just a bright point that fades, which the eye reads as blur rather than as light.
A px radius is used rather than a percentage. A percentage radius in a radial gradient resolves against the element box, so the same declaration produces a beam of a different physical size in every panel it is dropped into, and the effect stops being reusable.
Position comes from
useSmoothPointerwith a very low smoothing factor, so the beam has a small amount of drag.dampis frame-rate independent — the smoothing constant is the fraction of the gap left after one full second — so the same value produces the same feel at 60Hz and 144Hz rather than the beam arriving twice as fast on a fast display.The mask is written in a
useRafcallback, not in the pointer handler. Settingmask-imageinvalidates paint for the layer, and a fast pointer delivers two or three events per frame, so a per-event write does the same expensive work several times to produce one visible result.The layer is seeded with a mask parked two radii off-panel, so the first painted frame — before rAF has run once — cannot show the layer unmasked. Nothing is written until
pointer.activeis true, which is what stops the beam flashing at the hook's centre-parked seed position on mount.Both
maskImageandWebkitMaskImageare set. This is not defensive habit: released Safari versions mishandle gradient masks without the prefix, and the failure mode is the layer being fully visible or fully invisible rather than slightly wrong.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| radius | = 220 | Beam radius in px, giving a 132px solid core and a 92px falloff. Below ~120 the core is too small to hold a line of text and the effect becomes a magnifier; above ~350 in a small panel the falloff never completes inside the box and the beam reads as an uneven wash. |
| CORE_STOP | = 0.3 | Where the solid region ends. This is the single number that separates "a light" from "a smudge". At 0 there is no lit area, only a peak; past ~0.5 the edge becomes hard enough that it reads as a cut-out hole instead of illumination. |
| EDGE_STOP | = 0.72 | Where the falloff reaches zero. Short of 100% so the gradient completes inside its own radius and the beam never shows a visible circular boundary at the edge of the declared circle. |
| smoothing | = 0.0006 | Fraction of the remaining distance still uncovered after one second — so the beam is effectively caught up within about 100ms. Enough drag to give the light some weight, not enough for the beam to visibly trail behind the thing you are pointing at, which would make it useless for reading. |
| FADE_MS | = 260 | Enter and leave fade on the whole layer. Long enough that the beam does not blink on and off as the pointer crosses the edge, short enough that leaving does not leave a light hanging in an unhovered panel. |
| rest mask offset | = -radius * 2 | Where the seeded mask sits before the first frame. Two radii guarantees no part of the falloff is inside the box at any panel size, so nothing is lit before the pointer has been seen. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The spotlight is still drawn — it is a hover affordance and removing it would hide content — but it never moves. The mask is written once at 50% across and 45% down, the rAF loop is left disabled, and no pointer listeners attach.
Removes the enter and leave listeners, flips the enabled flag so any in-flight frame is a no-op, and clears the inline maskImage, WebkitMaskImage and opacity. useRaf and useSmoothPointer unsubscribe themselves. Nothing was allocated beyond the two strings written per frame.
Move across the panel and a soft round beam follows the pointer with a slight drag, lighting the content under it. The centre of the beam is evenly lit rather than brightest at one point — cover the middle third with a finger and the light should look flat, not peaked. Enter and leave and the whole layer fades over about a quarter of a second rather than blinking. On first load, before you move, nothing is lit. Check in Safari that the beam appears at all. On touch the layer is fully visible with no mask.
Pitfalls
- A linear falloff from 0%. Without a flat core there is no lit region, just a bright centre that fades continuously, and the result reads as a blurred blob rather than a light. This is the most common way this effect is built and the most common reason it looks cheap.
- Setting only
maskImage. Released Safari versions mishandle gradient masks without-webkit-mask-image, and the failure is total — the layer is either always visible or never visible — so it does not look like a mask bug when reported. - Writing the mask in the
pointermovehandler. Every write invalidates paint for the layer, and a fast pointer produces several events per frame, so the same paint is thrown away two or three times before anything reaches the screen. - A percentage radius.
circle 30% at ...resolves against the element box, so the beam is a different size in every container and the constant that looked right in the design mock is wrong everywhere else. - Writing the mask before the pointer has actually been seen.
useSmoothPointerparks at the element centre so the first movement eases out sensibly, but that seed is not a real position — treating it as one flashes a spotlight in the middle of the panel on mount. - Masking a layer containing interactive elements.
mask-imagedoes not clip hit testing, so links in the dark region are still clickable while being invisible. Either keep the masked layer non-interactive or accept that the mask is purely decorative. - Putting the effect on a layer with its own background. The mask reveals the layer, and a masked opaque background is a visible disc of colour, not a light. The lit layer needs a transparent background so the beam shows content, not a shape.
Notes
- Gated on
(pointer: fine). On touch the mask is removed entirely and the lit layer shows in full rather than staying hidden — an effect that cannot be operated must not be allowed to hide content.useSmoothPointeris also configured withtouch: falseso a scroll drag can never move the beam. - Reduced motion keeps the spotlight but pins it: the mask is written once, centred slightly above middle, and never updated. The affordance survives; the chasing does not.
- Scoped to its own container and measured against its rect, so several instances can appear on one page without interfering.
- The masked layer is decorative by construction. If it holds links, remember that a mask hides paint but not hit testing.