Cursor Circle Image Reveal — CSS Mask Hover Snippet
Cursor Circle Image Reveal · Animations · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="cm-stage">
<p class="cm-hint">Move your cursor across the photo — a circular window reveals the alternate version underneath</p>
<div class="cm-frame" id="cmFrame">
<div class="cm-base" aria-hidden="true"></div>
<div class="cm-mask" id="cmMask" aria-hidden="true"></div>
<span class="cm-tag cm-tag-day">Day</span>
<span class="cm-tag cm-tag-night">Night</span>
</div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0a0a14; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.cm-stage { display: flex; flex-direction: column; align-items: center; gap: 24px; padding: 24px; }
.cm-hint { font-size: 13px; color: #64748b; text-align: center; max-width: 360px; }
.cm-frame {
position: relative;
width: min(480px, 90vw);
height: 320px;
border-radius: 20px;
overflow: hidden;
cursor: none;
border: 1px solid #26263f;
}
.cm-base, .cm-mask {
position: absolute;
inset: 0;
}
/* "Day" layer — always fully visible underneath. */
.cm-base {
background: linear-gradient(160deg, #7dd3fc 0%, #38bdf8 35%, #fbbf24 70%, #fb923c 100%);
}
.cm-base::after {
content: '';
position: absolute; inset: 0;
background:
radial-gradient(circle 40px at 75% 25%, #fff8, transparent 70%),
linear-gradient(0deg, #0ea5e955 0%, transparent 40%);
}
/* "Night" layer — only visible through the circular mask that follows the cursor. */
.cm-mask {
background: linear-gradient(160deg, #0f172a 0%, #1e1b4b 45%, #312e81 100%);
-webkit-mask-image: radial-gradient(circle 90px at var(--cm-x, -200px) var(--cm-y, -200px), #000 98%, transparent 100%);
mask-image: radial-gradient(circle 90px at var(--cm-x, -200px) var(--cm-y, -200px), #000 98%, transparent 100%);
transition: -webkit-mask-image 0.02s linear;
}
.cm-mask::after {
content: '';
position: absolute; inset: 0;
background-image:
radial-gradient(2px 2px at 20% 30%, #fff, transparent),
radial-gradient(2px 2px at 60% 15%, #fff, transparent),
radial-gradient(1.5px 1.5px at 80% 40%, #fff, transparent),
radial-gradient(1.5px 1.5px at 35% 65%, #fff, transparent),
radial-gradient(2px 2px at 90% 70%, #fff, transparent),
radial-gradient(1.5px 1.5px at 10% 80%, #fff, transparent);
opacity: 0.8;
}
.cm-tag {
position: absolute;
top: 14px;
font-size: 11px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase;
background: rgba(0,0,0,0.45); color: #fff;
padding: 5px 10px; border-radius: 999px;
pointer-events: none;
}
.cm-tag-day { left: 14px; }
.cm-tag-night { right: 14px; }// A circular CSS mask-image is repositioned every mousemove via CSS custom
// properties, so the "night" layer only shows through a small circular
// window that follows the cursor exactly — like a flashlight revealing a
// second image underneath the first, rather than a text/color spotlight.
var frame = document.getElementById('cmFrame');
var mask = document.getElementById('cmMask');
function setSpot(x, y) {
frame.style.setProperty('--cm-x', x + 'px');
frame.style.setProperty('--cm-y', y + 'px');
mask.style.setProperty('--cm-x', x + 'px');
mask.style.setProperty('--cm-y', y + 'px');
}
frame.addEventListener('mousemove', function (e) {
var rect = frame.getBoundingClientRect();
setSpot(e.clientX - rect.left, e.clientY - rect.top);
});
frame.addEventListener('mouseleave', function () {
setSpot(-200, -200); // move the window fully off-frame
});
frame.addEventListener('touchmove', function (e) {
var t = e.touches[0];
if (!t) return;
var rect = frame.getBoundingClientRect();
setSpot(t.clientX - rect.left, t.clientY - rect.top);
}, { passive: true });Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), 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 Circle Image Reveal — CSS mask-image radial-gradient Following the Cursor

This effect stacks two full-size image layers and cuts a small circular window through the top layer that follows the cursor, so moving the mouse feels like sliding a flashlight or magnifying glass across the surface, exposing the second layer underneath only where the circle currently sits. It's a different interaction from cursor spotlight reveal, which dims and lights up text on a single layer — here two entirely different visuals (day and night versions of the same scene) exist simultaneously, and the cursor decides which one is visible at each point.
The two-layer stack
.cm-base is the bottom layer and is always fully visible — think of it as the "default" image. .cm-mask sits directly on top at the same size (position: absolute; inset: 0) holding the alternate visual, but with a CSS mask-image applied that hides all of it except a small circle.
The circular mask
The mask is a radial-gradient(circle 90px at var(--cm-x, -200px) var(--cm-y, -200px), #000 98%, transparent 100%) used as mask-image (and -webkit-mask-image for Safari). Wherever the gradient is opaque black, the layer beneath shows through; wherever it fades to transparent, that part of the top layer is invisible, letting the base layer beneath show instead. Because the gradient's center position is driven entirely by two CSS custom properties, moving those properties is all it takes to move the visible window.
Tracking the cursor
A single mousemove listener on the frame computes the cursor's position relative to the frame with getBoundingClientRect(), then calls setSpot(x, y), which writes --cm-x and --cm-y via style.setProperty. Because these are plain CSS custom properties read directly inside the radial-gradient() function, the browser's own compositor updates the mask position — there's no JavaScript recalculating gradients or repainting canvases on every frame, keeping the effect smooth even on modest hardware.
Off-frame reset
On mouseleave, setSpot(-200, -200) moves the circular window's center far outside the visible frame, so the mask closes to fully hide the alternate layer again without any special-case CSS — the same radial-gradient math simply places the circle where nothing is visible.
Touch support
A parallel touchmove listener reads e.touches[0] and applies the same relative-position math, so dragging a finger across the frame on mobile reveals the layer underneath exactly like a mouse would.
Adapting it to real photography
Swap the two gradient-based .cm-base/.cm-mask backgrounds for background-image: url(...) pointing at your own day/night, before/after, or sketch/final image pairs — the masking mechanism itself doesn't care what's underneath, only that both layers are the same size and positioned identically.
Step by step
How to Use
- 1Move the cursor over the frameDrag your cursor across the image area — a circular window follows it, revealing the night-sky layer underneath the day layer.
- 2Leave the frameMove the cursor off the frame and the window closes, hiding the alternate layer completely again.
- 3Swap in real imagesReplace the .cm-base and .cm-mask backgrounds with background-image: url(your-image.jpg) for real before/after photography.
- 4Resize the reveal windowChange the 90px radius value inside the radial-gradient() in the .cm-mask mask-image rule.
- 5Soften or harden the edgeAdjust the 98% / 100% gradient stops — closer together gives a crisp circle edge, farther apart gives a soft feathered edge.
- 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 CSS mask-image on the top layer is a radial-gradient centered at a position controlled by two custom properties (--cm-x, --cm-y). Wherever the gradient is opaque, the top layer shows; wherever it fades to transparent, the layer beneath shows through instead. Moving the custom properties moves the visible circle.
Writing to two custom properties via style.setProperty lets the browser's compositor handle repositioning the existing gradient efficiently. Rebuilding and reassigning the full mask-image string on every mousemove would work too, but is unnecessary string work compared to updating two numeric properties.
Yes. Replace the background (and remove the ::after decorative layers) on .cm-base and .cm-mask with background-image: url(...) pointing at two same-size images — the masking logic is unaffected by what image is underneath.
Change the 90px value inside radial-gradient(circle 90px at ...) in the .cm-mask mask-image (and -webkit-mask-image) rule.
Yes. A touchmove listener reads the first active touch point and applies the same relative-position calculation as the mouse handler, so dragging a finger reveals the layer underneath.
Yes. Click "JSX" for a React component. Track the frame with a ref, compute the relative position inside onMouseMove using getBoundingClientRect, and call ref.current.style.setProperty directly for both custom properties to avoid state-driven re-renders on every move.