Cursor Spotlight Text Fill — Mask-Image Reveal JS
Cursor Spotlight Text Fill · Animations · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="scene">
<p class="sub">Move your mouse across the headline</p>
<h1 class="spotlight-heading" data-text="Light follows every letter">Light follows every letter</h1>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0a0a12; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; }
.scene { text-align: center; max-width: 820px; }
.sub { font-size: 12px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: #4c1d95; margin-bottom: 18px; }
.spotlight-heading {
position: relative;
font-size: clamp(28px, 7vw, 58px);
font-weight: 800;
letter-spacing: -0.01em;
line-height: 1.15;
color: #2a2a38;
}
.spotlight-heading::after {
content: attr(data-text);
position: absolute;
inset: 0;
color: #fff;
background: linear-gradient(90deg, #a855f7, #ec4899, #f97316);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
-webkit-mask-image: radial-gradient(circle 140px at var(--x, 50%) var(--y, 50%), #000 0%, #000 30%, transparent 75%);
mask-image: radial-gradient(circle 140px at var(--x, 50%) var(--y, 50%), #000 0%, #000 30%, transparent 75%);
pointer-events: none;
}const heading = document.querySelector('.spotlight-heading');
function moveSpotlight(clientX, clientY) {
const rect = heading.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * 100;
const y = ((clientY - rect.top) / rect.height) * 100;
heading.style.setProperty('--x', x + '%');
heading.style.setProperty('--y', y + '%');
}
window.addEventListener('pointermove', (e) => moveSpotlight(e.clientX, e.clientY));
window.addEventListener('pointermove', (e) => {
const rect = heading.getBoundingClientRect();
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
heading.style.opacity = inside ? '' : '';
});
let angle = 0;
function idleSweep() {
const rect = heading.getBoundingClientRect();
const cx = 50 + Math.sin(angle) * 30;
const cy = 50 + Math.cos(angle * 0.7) * 20;
angle += 0.015;
heading.style.setProperty('--x', cx + '%');
heading.style.setProperty('--y', cy + '%');
requestAnimationFrame(idleSweep);
}
requestAnimationFrame(idleSweep);
window.addEventListener('pointermove', () => {
cancelAnimationFrame(idleSweep);
}, { once: true });Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Cursor Spotlight Text Fill — mask-image radial-gradient Tracking CSS Custom Properties

A cursor spotlight text fill shows a headline in a muted flat color by default, with a small circle of full gradient color that follows the mouse across the letters — as if the reader were holding a flashlight over dim text. It is a more restrained cousin of animated gradient text: instead of the whole headline shimmering continuously, only the area under the cursor lights up, which draws attention exactly where the user is looking rather than everywhere at once. Pair it with cursor spotlight reveal for a matching background treatment.
Two overlapping copies of the same text
The trick relies on content: attr(data-text) inside a ::after pseudo-element that sits exactly on top of the real heading via position: absolute; inset: 0. The base <h1> text renders in a flat muted grey (color: #2a2a38). The ::after copy renders in a gradient using the same background-clip: text technique as animated gradient text — but crucially, that gradient copy is masked so only a small circular region around the cursor is actually visible.
The radial-gradient mask that follows the cursor
mask-image: radial-gradient(circle 140px at var(--x, 50%) var(--y, 50%), #000 0%, #000 30%, transparent 75%) defines a circular mask centered at CSS custom properties --x and --y. Where the mask is opaque black, the gradient-colored ::after text shows through; where it fades to transparent, the muted base heading shows instead. Because radial-gradient's center accepts any length or percentage — including a custom property — updating --x/--y in JavaScript moves the visible "hole" in real time without touching any other CSS.
Updating custom properties from pointermove
moveSpotlight(clientX, clientY) converts the pointer's viewport coordinates into a percentage relative to the heading's own getBoundingClientRect(), then calls heading.style.setProperty('--x', x + '%') and the same for --y. Because the mask-image reads these custom properties directly, no re-render of the mask string is needed — the browser just recomputes the radial-gradient's center on every pointer move, which is cheap because masking is GPU-composited.
The idle sweep before the first interaction
Before any pointermove event has fired, the spotlight would otherwise sit static at 50%/50%. idleSweep() runs a requestAnimationFrame loop that walks --x/--y through a lissajous-style path using Math.sin/Math.cos at different frequencies, so the spotlight glides across the headline on its own to hint at the effect. The loop cancels itself the first time the user actually moves the pointer, handing control over to moveSpotlight.
Step by step
How to Use
- 1Move the mouse over the headlineA small circle of gradient color follows the cursor across the letters. Before moving the mouse, an idle sweep animates the spotlight automatically.
- 2Change the headline textUpdate both the visible text inside <h1> and its data-text attribute in the HTML panel — they must match exactly since data-text drives the ::after gradient copy.
- 3Adjust the spotlight sizeIn the CSS panel, change 140px inside the two radial-gradient() calls (mask-image and -webkit-mask-image) — larger reveals more text at once.
- 4Change the gradient colorsEdit the linear-gradient stops on .spotlight-heading::after in the CSS panel to match your brand palette.
- 5Change the base (unlit) text colorUpdate color: #2a2a38 on .spotlight-heading — this is what shows outside the spotlight radius.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a React + Tailwind version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
A ::after pseudo-element duplicates the heading text in a gradient color and sits exactly on top of the base heading via position: absolute; inset: 0. A mask-image radial-gradient centered on the cursor position makes only a small circular region of that gradient copy opaque — everywhere else it is fully transparent, letting the flat-colored base heading show through instead.
radial-gradient() accepts any valid length for its center, including var(--x) and var(--y). Updating those two custom properties with element.style.setProperty() on every pointermove is far cheaper than reconstructing and reassigning the entire mask-image string every frame, and the browser only needs to recompute the gradient center.
Without any pointer interaction, --x and --y would sit at their default 50%/50%, leaving the spotlight static and easy to miss on page load. A requestAnimationFrame loop drives the spotlight through a slow, automatic sine/cosine path until the user actually moves their mouse, at which point the loop cancels itself.
pointermove fires for touch drags but not for simple taps, so on mobile the idle sweep is the primary way the effect is seen. Consider adding a touchmove listener that mirrors moveSpotlight for a touch-and-drag variant.
The base <h1> renders its normal text content in the flat color. The gradient copy comes from content: attr(data-text) on the ::after pseudo-element, which reads a separate data-text attribute rather than the element's own text — the two must be kept in sync manually when you edit the headline.
Yes. Click "JSX" for a React component. Use a ref on the heading, update its style custom properties directly in a pointermove handler via ref.current.style.setProperty(), and drive the idle sweep from a useEffect-managed requestAnimationFrame loop that you cancel on unmount.