Spotlight Reveal
Content hidden in a dark zone, revealed under the cursor by a radial CSS mask: two percentage-based variables simultaneously drive the visibility window and the colored glow that illuminates what it lets through.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Cursors has 26 effects, including 1 free. Explore the category →
3 usage examples



How it works
The effect relies on two layers inside the .spotlight-zone container. At the bottom, the ::before pseudo-element renders a ghost text at 10% opacity. On top, the .spotlight-reveal div covers the entire zone through inset: 0: it does not hide content, it is the content — the revealed text lives directly inside it. This div receives two radial-gradients simultaneously, both driven by the same --x and --y variables: a background that casts an indigo glow (rgba(99, 102, 241, .3), 80 px radius) and a mask that cuts a circular window of 100 px. The mask does all the work: where it is black, the element is visible; where it is transparent, it disappears.
The mousemove listener is attached to #zone-spotlight, not to .spotlight-reveal. On every move, getBoundingClientRect() reads the zone's geometry, and the script computes coordinates as percentages: x = ((e.clientX − rect.left) / rect.width) * 100, same for Y. These values are written into --x and --y via style.setProperty(). The browser instantly recomputes both gradients — window and glow move together in a single call. No mouseleave is declared: when the cursor leaves the zone, the variables stay frozen.
Choosing a CSS mask — rather than an opaque overlay — changes the reading: the content layer opens up, not a lid lifted. What falls outside the circle does not render but stays in the DOM — the hidden text remains accessible to assistive technologies. The two radii — 80 px for the glow, 100 px for the window — are independent. Their 20 px gap creates a fringe where the mask fades before the color reaches zero: the illumination edge appears blended, not sharply cut.
Accessibility
- prefers-reduced-motion: absent from the code. Gate the listener registration behind
window.matchMedia('(prefers-reduced-motion: reduce)')before deploying — in reduced-motion mode, leave the variables at their50% 50%fallback for a centered, static mask. - On touch screens, no
mousemovefires: the variables stay at their default values, the window stays centered, and the content is statically visible there — a sound fallback, but not interactive. Never make a critical action depend on hover if part of your audience has no mouse. - Without
mouseleave, the spotlight stays frozen at the last position when the cursor exits. If the masked content is functional, add amouseleavelistener that resets--xand--yto50%. - The content of
.spotlight-revealis in the DOM — the mask does not touch the accessibility tree. But the::beforeghost text and the revealed text coexist for assistive technologies: if the::beforecarries an instruction, mark itaria-hidden= rue\.
Browser compatibility
The unprefixed mask property is supported in Chrome 120+, Firefox 53+, and Edge 79+. For Safari, -webkit-mask is required up to 15.3; from 15.4 the unprefixed alias works. The rest — CSS variables, radial-gradient, inset: 0 — poses no issue. The JavaScript uses arrow functions (ES2015): IE 11 stops before the first event.
Without JavaScript, the variables stay at their <code>50% 50%</code> fallback: the window remains centered and static, the content is visible — a sound fallback, not a blank screen. To cover Safari before 15.4, duplicate both mask properties with the <code>-webkit-mask</code> prefix before <code>mask</code>. No other dependency.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
| Mask radius (100px in the CSS) | 100 px | The mask: radial-gradient(circle 100px at …) property controls the window size. Increase it to reveal more at once; shrink it for a narrow window. This radius is independent of the glow — keep them different to preserve the blending fringe. |
| Glow radius (80px in the background) | 80 px | Drives only the colored light patch. Smaller than the mask radius, it creates a lit area that fades before the window edge — the flashlight effect. Match both radii for a sharp, colored edge. |
| Glow color and opacity (rgba(99, 102, 241, .3)) | indigo at 30% | Change all four RGBA values to adapt hue and intensity. 0.1 = near-invisible luminescence; 0.7 = dominant glow that strongly tints the content. On a light background, a dark glow gives an inverted lens effect. |
| Revealed content (text node inside .spotlight-reveal) | plain text | Replace the text node with any HTML: image, SVG, badge, link. The mask applies to everything inside .spotlight-reveal. Avoid form controls — their click area follows the mask's visible edge, which can surprise users. |
| Ghost text (::before on .spotlight-zone) | "Déplacez la souris ici" at 10% opacity | Change the content value for a different prompt, lower opacity to 0.05, or remove it if the zone is self-explanatory. This pseudo-element is separate from .spotlight-reveal and unaffected by the mask. |
| Cursor-exit behavior (no mouseleave listener) | frozen at last position | Without mouseleave, the spotlight stays where the cursor last stopped. Add a listener that resets --x and --y to 50% to re-center on exit. Or set both to -200% to push the mask out of the zone and hide all content. |
FAQ
mouseleave, fx-0214 recenters on exit; fx-0214 hides the system cursor (cursor: none), fx-0201 leaves it visible.ids via getElementById — a single pair. For multiple instances, switch to querySelectorAll('.spotlight-zone') and loop, retrieving each child .spotlight-reveal and attaching the listener with local references in the closure. Since the variables are written to each element's inline style, instances are fully independent.radial-gradient(), which interprets them as a ratio of the element's dimensions. If the zone is resized, the next mousemove re-reads getBoundingClientRect() and corrects positioning automatically — no ResizeObserver needed, unlike a pixel-based approach.