Cursor Spotlight
A 95% black veil with a 120 px hole that follows the pointer, laid over content kept at 15% opacity at rest and raised to 100% on hover: the text is readable only under the spotlight. The native cursor stays visible — this is lighting, not a replacement cursor.
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
Three layers inside the #zone-spotlight-cursor area (.spotlight-cursor-zone, 300 px tall, #0f0f1a background, overflow: hidden). Underneath, .spotlight-content: position: absolute; inset: 0, centered flex column, opacity: .15 and transition: opacity .3s. On top, #spotlightOverlay (.spotlight-cursor-overlay): inset: 0, pointer-events: none and a radial-gradient(circle 120px at var(--spot-x, 50%) var(--spot-y, 50%), transparent 0%, rgba(0,0,0,.95) 100%). The .info-text, declared after the overlay in the DOM, stays above the veil.
The JS is a single mousemove listener on the zone: x = (clientX − rect.left) / rect.width × 100, same for y, then spotlightOverlay.style.setProperty('--spot-x', x + '%'). Coordinates are percentages of the zone, so the gradient center follows the pointer whatever the container's size (measured: --spot-x: 25%, --spot-y: 50% for a pointer at a quarter of the width). No requestAnimationFrame, no smoothing: the hole is glued to the pointer, and nothing runs outside hover (0 rAF calls/s measured).
The gradient goes from transparent at the center to rgba(0,0,0,.95) at 120 px: not a sharp disc but a fade over a 120 px radius, after which the veil is a uniform 95%. Content is lit only inside a circle of about 240 px across, and CSS alone makes it readable: .spotlight-cursor-zone:hover .spotlight-content { opacity: 1 }, with the 300 ms transition. On leaving, :hover drops and the content falls back to 15%.
No mouseleave listener: the --spot-x/--spot-y variables keep their last value (measured: still 25%/50% after leaving), so on the next entry the hole sits where it was left until the first mousemove. No cursor: none: the system arrow remains. Two details in the shipped HTML: the h3 carries an inline style="font-family: Inter, …" (system fallback if Inter isn't loaded) and keeps its browser margins (20 px measured, not reset).
Accessibility
- Deliberately zero contrast at rest: the white title at 15% opacity on
#0f0f1agives 1.5:1, the paragraph (rgba(255,255,255,.7)) 1.3:1, and under the veil outside the circle 1.07:1. On hover, under the spotlight: 19:1 and 9.5:1. The content must therefore carry nothing essential, or be repeated elsewhere; otherwise raise the resting opacity to.5(5.3:1 for white). - No hover, no reading: on keyboard and touch,
:hoverdoesn't reliably stick and the content stays at 15%. Add@media (hover: none) { .spotlight-content { opacity: 1 } .spotlight-cursor-overlay { display: none } }and, if the zone holds a focusable element,.spotlight-cursor-zone:focus-within .spotlight-content { opacity: 1 }. - prefers-reduced-motion: the only autonomous motion is the 300 ms opacity transition; the hole follows the hand, it isn't animated. Nothing mandatory, but
@media (prefers-reduced-motion: reduce) { .spotlight-content { transition: none } }skips the fade for those who asked. - Screen readers: the overlay is an empty
div, the content is ordinary DOM announced normally whatever its visibility — the right situation. The.info-text(“Spotlight effect”) is read too: remove it or set itaria-hiddenoutside the demo. - The content is
position: absolute; inset: 0in a fixed-height zone (300 px) withoverflow: hidden: text longer than the zone is cut off with no scrolling. Plan amin-heightor short content; at 200% zoom, check that nothing disappears.
Browser compatibility
Requires CSS custom properties with setProperty, the inset shorthand and radial-gradient() with explicit size and position. ES2015 JS (arrow functions, const). Zero dependencies.
Without JavaScript, the hole stays centered (50%/50% fallbacks) and hover still lights the content: a static, usable vignette. Without inset (Safari before 14.1, Firefox before 66), content and overlay lose their size: no more veil, the content shows at 15% then 100% on hover.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="cursor-zone spotlight-cursor-zone" id="zone-spotlight-cursor">
<div class="spotlight-content">
<h3 style="font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;">Lit content</h3>
<p>The cursor lights the content</p>
</div>
<div class="spotlight-cursor-overlay" id="spotlightOverlay"></div>
<span class="info-text" style="color: rgba(255,255,255,0.5);">Spotlight effect</span>
</div>
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 |
|---|---|---|
Hole radius (CSS — circle 120px) |
120 px | Distance over which the veil goes from transparent to 95%. 80 px = narrow flashlight; 200 px = wide halo that reveals a whole block. |
Veil density (CSS — rgba(0,0,0,.95)) |
.95 | Opacity of the black outside the circle. .8 lets the content show through; 1 hides it completely (the pointer is then the only window). |
| Resting opacity (CSS — .spotlight-content opacity) | .15 | Content visibility without hover. This is the accessibility knob: .5 gives 5.3:1 on white, 1 keeps everything readable and leaves only the veil. |
| Transition (CSS — .spotlight-content) | opacity .3s | Duration of the content lighting up and dimming on hover. The hole itself is never animated (variables aren't interpolated). |
Gradient profile (CSS — 0% / 100% stops) |
transparent 0% → black 100% | transparent 45%, rgba(0,0,0,.95) 100% creates a clear disc with a short soft edge; two identical stops give a hard edge. |
| Zone background (CSS — .spotlight-cursor-zone background) | #0f0f1a | Color seen through the veil and under the hole. A light background breaks the effect: keep a dark shade, or raise the veil to 1. |
Reset on leave (JS — mouseleave listener to add) |
absent | setProperty('--spot-x', '50%') and '--spot-y', '50%' in a mouseleave re-center the hole instead of leaving it at the last hovered point. |
FAQ
opacity: .15 and the 95% veil on top. Measured: 1.5:1 for the title at rest, 1.07:1 under the veil. If you want readable content with a simple lamp effect on hover, raise the resting opacity to 1 and keep the overlay: the spotlight becomes ambient lighting and the text stays compliant.transition: --spot-x is an untyped variable, the browser can't interpolate between two percentages and jumps from one value to the other. Two routes: register the variable with @property --spot-x { syntax: '<percentage>'; inherits: false; initial-value: 50% } then transition: --spot-x .15s, --spot-y .15s on the overlay (Chrome 85+, Safari 16.4+, Firefox 128+); or smooth in JS with a requestAnimationFrame loop and a factor, like Dot Follower.mouseleave listener: the variables keep their last value (measured 25%/50% after leaving) and the next hover reuses them until the first movement. Add zone.addEventListener('mouseleave', () => { overlay.style.setProperty('--spot-x', '50%'); overlay.style.setProperty('--spot-y', '50%'); }). The content does fall back to 15%, since :hover is handled by CSS.