Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 114% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 4% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 147% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 45% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 79% | 0% |
mask-image to the reveal image.requestAnimationFrame.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.
260px.140px to 220px.0.1.0.14.0%: alpha 140%: alpha 160%: alpha 0.7575%: alpha 0.488%: alpha 0.12100%: alpha 00.Use real images so loading, intrinsic sizing, and accessibility remain predictable.
html<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.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(0 0 0 / 0.75) 60%, rgb(0 0 0 / 0.4) 75%, rgb(0 0 0 / 0.12) 88%, transparent 100% ); 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(0 0 0 / 0.75) 60%, rgb(0 0 0 / 0.4) 75%, rgb(0 0 0 / 0.12) 88%, transparent 100% ); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; will-change: -webkit-mask-image, mask-image; } @media (hover: none), (pointer: coarse) { .reveal-hover__image--overlay { display: none; } }
Keep object-fit and object-position identical on both layers. A one-pixel mismatch becomes obvious inside the spotlight.
Run the animation loop only while values are changing. Convert clientX and clientY with getBoundingClientRect(); page coordinates will drift after scroll.
jsfunction 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 Math.min(260, Math.max(140, element.clientWidth * 0.22)); }; const updateTarget = (clientX, clientY) => { const rect = element.getBoundingClientRect(); state.clientX = clientX; state.clientY = clientY; state.targetX = clientX - rect.left; state.targetY = clientY - rect.top; }; const schedule = () => { if (!state.frame) state.frame = requestAnimationFrame(tick); }; const tick = () => { state.frame = 0; const positionEase = reduceMotion.matches ? 1 : 0.1; const radiusEase = reduceMotion.matches ? 1 : 0.14; state.x += (state.targetX - state.x) * positionEase; state.y += (state.targetY - state.y) * positionEase; state.radius += (state.targetRadius - state.radius) * radiusEase; element.style.setProperty("--reveal-x", `${state.x.toFixed(2)}px`); element.style.setProperty("--reveal-y", `${state.y.toFixed(2)}px`); element.style.setProperty("--reveal-radius", `${state.radius.toFixed(2)}px`); const unsettled = Math.abs(state.targetX - state.x) > 0.1 || Math.abs(state.targetY - state.y) > 0.1 || Math.abs(state.targetRadius - state.radius) > 0.1; if (unsettled) schedule(); }; const onPointerEnter = (event) => { state.inside = true; updateTarget(event.clientX, event.clientY); if (state.radius < 0.5) { state.x = state.targetX; state.y = state.targetY; } state.targetRadius = getRadius(); schedule(); }; const onPointerMove = (event) => { updateTarget(event.clientX, event.clientY); // A page can load with the pointer already over this element, so the // first pointermove may arrive without a preceding pointerenter. if (!state.inside) { state.inside = true; if (state.radius < 0.5) { state.x = state.targetX; state.y = state.targetY; } state.targetRadius = getRadius(); } schedule(); }; const hideReveal = () => { state.inside = false; state.targetRadius = 0; schedule(); }; const onViewportChange = () => { if (!state.inside) return; updateTarget(state.clientX, state.clientY); state.targetRadius = getRadius(); schedule(); }; element.addEventListener("pointerenter", onPointerEnter); element.addEventListener("pointermove", onPointerMove); element.addEventListener("pointerleave", hideReveal); element.addEventListener("pointercancel", hideReveal); window.addEventListener("blur", hideReveal); window.addEventListener("scroll", onViewportChange, { passive: true }); const resizeObserver = new ResizeObserver(onViewportChange); resizeObserver.observe(element); return () => { if (state.frame) cancelAnimationFrame(state.frame); resizeObserver.disconnect(); element.removeEventListener("pointerenter", onPointerEnter); element.removeEventListener("pointermove", onPointerMove); element.removeEventListener("pointerleave", hideReveal); element.removeEventListener("pointercancel", hideReveal); window.removeEventListener("blur", hideReveal); window.removeEventListener("scroll", onViewportChange); }; } const revealHoverCleanups = Array.from( document.querySelectorAll("[data-reveal-hover]") ).map(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.
Add a subtle grid only when it supports the art direction.
48px SVG or CSS grid.0.1.±16px.0.06, slower than the reveal.prefers-reduced-motion.The grid is atmosphere, not the focal interaction. It should barely move.
When a foreground card must reveal the same alternate treatment:
getBoundingClientRect().left and .top before setting its local mask coordinates.requestAnimationFrame loop.canvas.toDataURL() every frame.will-change only on the masked overlay.mask-* and -webkit-mask-*.40% of the radius.Other measured skills in the registry, with their headline benchmark lift.