| name | reveal-hover-effect |
| description | Build cursor-following spotlight reveals that expose a second aligned image through a soft radial mask. Use for hover-to-color, before-and-after, x-ray, material, texture, product-detail, and illustrated hero effects where a desaturated or embossed base image should remain visible while another treatment follows an eased pointer. |
Reveal Hover Effect
Core Contract
- Prepare two images with identical dimensions, composition, crop, and focal point.
- Keep the base image fully visible.
- Stack the reveal image directly above it.
- Apply a feathered radial
mask-image to the reveal image.
- Track pointer coordinates in the component's local coordinate space.
- Ease the rendered position toward the raw pointer with
requestAnimationFrame.
- Collapse the mask on pointer exit; never leave a stale spotlight behind.
Default to CSS masks instead of generating a canvas data URL every frame. The CSS version preserves the same look with less allocation and simpler cleanup.
Motion Defaults
- Desktop spotlight radius:
260px.
- Compact spotlight radius:
140px to 220px.
- Pointer easing:
0.1.
- Radius easing:
0.14.
- Mask stops:
0%: alpha 1
40%: alpha 1
60%: alpha 0.75
75%: alpha 0.4
88%: alpha 0.12
100%: alpha 0
- Initial state: base image only.
- Exit state: radius eases back to
0.
- Cursor: keep the native cursor unless the design explicitly needs a custom one.
Markup
Use real images so loading, intrinsic sizing, and accessibility remain predictable.
<figure class="reveal-hover" data-reveal-hover data-reveal-radius="260">
<img
class="reveal-hover__image reveal-hover__image--base"
src="/images/product-linework.webp"
alt="Sculpted product shown in a pale linework treatment"
width="1600"
height="1000"
decoding="async"
/>
<img
class="reveal-hover__image reveal-hover__image--overlay"
src="/images/product-color.webp"
alt=""
width="1600"
height="1000"
decoding="async"
aria-hidden="true"
/>
</figure>
Keep the overlay decorative when both images communicate the same subject. If the comparison carries unique information, provide visible labels or a separate accessible description.
CSS Mask
.reveal-hover {
--reveal-x: 50%;
--reveal-y: 50%;
--reveal-radius: 0px;
position: relative;
overflow: clip;
isolation: isolate;
margin: 0;
background: #f3f1ec;
contain: paint;
}
.reveal-hover__image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.reveal-hover__image--base {
position: relative;
z-index: 0;
}
.reveal-hover__image--overlay {
position: absolute;
z-index: 1;
inset: 0;
pointer-events: none;
-webkit-mask-image: radial-gradient(
circle var(--reveal-radius) at var(--reveal-x) var(--reveal-y),
rgb(0 0 0 / 1) 0%,
rgb(0 0 0 / 1) 40%,
rgb( / ) ,
( / ) ,
( / ) ,
transparent
);
: (
circle (--reveal-radius) at (--reveal-x) (--reveal-y),
( / ) ,
( / ) ,
( / ) ,
( / ) ,
( / ) ,
transparent
);
-webkit-: no-repeat;
: no-repeat;
: -webkit-mask-image, mask-image;
}
(: none), (: coarse) {
{
: none;
}
}
Keep object-fit and object-position identical on both layers. A one-pixel mismatch becomes obvious inside the spotlight.
Eased Pointer Tracking
Run the animation loop only while values are changing. Convert clientX and clientY with getBoundingClientRect(); page coordinates will drift after scroll.
function initRevealHover(element) {
const overlay = element.querySelector(".reveal-hover__image--overlay");
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
if (!overlay || !finePointer.matches) return () => {};
const state = {
x: 0,
y: 0,
targetX: 0,
targetY: 0,
radius: 0,
targetRadius: 0,
clientX: 0,
clientY: 0,
inside: false,
frame: 0,
};
const getRadius = () => {
const requested = Number.parseFloat(element.dataset.revealRadius);
if (Number.isFinite(requested)) return requested;
return .(, .(, element. * ));
};
= () => {
rect = element.();
state. = clientX;
state. = clientY;
state. = clientX - rect.;
state. = clientY - rect.;
};
= () => {
(!state.) state. = (tick);
};
= () => {
state. = ;
positionEase = reduceMotion. ? : ;
radiusEase = reduceMotion. ? : ;
state. += (state. - state.) * positionEase;
state. += (state. - state.) * positionEase;
state. += (state. - state.) * radiusEase;
element..(, );
element..(, );
element..(, );
unsettled =
.(state. - state.) > ||
.(state. - state.) > ||
.(state. - state.) > ;
(unsettled) ();
};
= () => {
state. = ;
(event., event.);
(state. < ) {
state. = state.;
state. = state.;
}
state. = ();
();
};
= () => {
(event., event.);
(!state.) {
state. = ;
(state. < ) {
state. = state.;
state. = state.;
}
state. = ();
}
();
};
= () => {
state. = ;
state. = ;
();
};
= () => {
(!state.) ;
(state., state.);
state. = ();
();
};
element.(, onPointerEnter);
element.(, onPointerMove);
element.(, hideReveal);
element.(, hideReveal);
.(, hideReveal);
.(, onViewportChange, { : });
resizeObserver = (onViewportChange);
resizeObserver.(element);
{
(state.) (state.);
resizeObserver.();
element.(, onPointerEnter);
element.(, onPointerMove);
element.(, hideReveal);
element.(, hideReveal);
.(, hideReveal);
.(, onViewportChange);
};
}
revealHoverCleanups = .(
.()
).(initRevealHover);
In React, Vue, or Svelte, initialize after mount and call every returned cleanup during unmount. Do not create a new animation loop on every render.
Optional Grid Parallax
Add a subtle grid only when it supports the art direction.
- Use a
48px SVG or CSS grid.
- Keep opacity near
0.1.
- Normalize the eased pointer around the component center.
- Limit drift to
±16px.
- Ease grid offset with factor
0.06, slower than the reveal.
- Disable the drift under
prefers-reduced-motion.
The grid is atmosphere, not the focal interaction. It should barely move.
Nested Glass or Refraction Cards
When a foreground card must reveal the same alternate treatment:
- Stack the same base and reveal assets inside the card.
- Use the same viewport pointer state.
- Subtract the card's
getBoundingClientRect().left and .top before setting its local mask coordinates.
- Preserve the card's own crop and border radius.
- Update the card and hero from the same animation frame so the refraction does not lag.
Touch and Accessibility
- Default coarse pointers to the static base image.
- If the reveal contains meaningful information, add an explicit “Show alternate” control for touch and keyboard users.
- Do not rely on hover to expose navigation, pricing, instructions, or essential copy.
- Under reduced motion, update the spotlight directly without trailing easing and disable parallax.
- Keep the native cursor visible so the reveal center remains obvious.
Performance Rules
- Animate CSS custom properties from one
requestAnimationFrame loop.
- Stop the loop after the pointer and radius settle.
- Do not call
canvas.toDataURL() every frame.
- Keep both images at the same encoded dimensions and responsive source set.
- Use
will-change only on the masked overlay.
- Test Safari with both
mask-* and -webkit-mask-*.
- Avoid masking a huge page-sized layer when the effect only occupies one component.
Quick Checks
- Base and reveal assets remain pixel-aligned at every breakpoint.
- The reveal begins under the pointer rather than sweeping in from the component center.
- The first pointer movement reveals correctly when the page loads under a stationary cursor.
- The full-strength core holds through
40% of the radius.
- The feather reaches full transparency at the edge without a visible ring.
- Pointer exit and window blur collapse the mask.
- Scrolling while hovered does not detach the spotlight from the cursor.
- Touch users get a deliberate static or toggle fallback.
- Reduced-motion mode has no trailing movement or grid drift.
- The animation loop stops when idle and cleans up on route changes.