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.
.claude/skills/mengto-reveal-hover-effect/SKILL.md| 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.| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-15 | pass→pass | 15,007 | 17,118 | +14% | 1 | 1 | 0% | 2,994 | 6,987 | +133% | 0 | 0 | — |
case-01 | fail→pass | 16,772 | 21,701 | +29% | 1 | 1 | 0% | 3,757 | 8,039 | +114% | 0 | 0 | — |
case-02 | fail→pass | 26,014 | 13,929 | -46% | 1 | 1 | 0% | 6,171 | 6,418 | +4% | 0 | 0 | — |
case-03 | fail→fail | 26,540 | 24,712 | -7% | 1 | 1 | 0% | 6,200 | 9,135 | +47% | 0 | 0 | — |
case-04 | fail→pass | 11,780 | 11,345 | -4% | 1 | 1 | 0% | 2,166 | 5,347 | +147% | 0 | 0 | — |
case-05 | fail→pass | 14,225 | 4,313 | -70% | 1 | 1 | 0% | 2,621 | 3,805 | +45% | 0 | 0 | — |
case-06 | pass→pass | 13,817 | 11,938 | -14% | 1 | 1 | 0% | 2,741 | 5,205 | +90% | 0 | 0 | — |
case-07 | fail→pass | 12,529 | 6,188 | -51% | 1 | 1 | 0% | 2,317 | 4,136 | +79% | 0 | 0 | — |
case-08 | fail→pass | 13,381 | 16,168 | +21% | 1 | 1 | 0% | 2,633 | 6,143 | +133% | 0 | 0 | — |
case-09 | fail→pass | 12,899 | 5,552 | -57% | 1 | 1 | 0% | 2,171 | 3,913 | +80% | 0 | 0 | — |
case-10 | pass→pass | 13,935 | 14,062 | +1% | 1 | 1 | 0% | 2,391 | 5,575 | +133% | 0 | 0 | — |
case-11 | fail→pass | 9,402 | 2,146 | -77% | 1 | 1 | 0% | 1,728 | 3,368 | +95% | 0 | 0 | — |
case-12 | pass→pass | 10,421 | 10,529 | +1% | 1 | 1 | 0% | 1,960 | 4,914 | +151% | 0 | 0 | — |
case-13 | pass→pass | 11,748 | 11,179 | -5% | 1 | 1 | 0% | 2,404 | 5,288 | +120% | 0 | 0 | — |
case-14 | pass→pass | 13,750 | 7,827 | -43% | 1 | 1 | 0% | 2,510 | 4,306 | +72% | 0 | 0 | — |
case-16 | fail→pass | 12,894 | 6,739 | -48% | 1 | 1 | 0% | 2,371 | 4,331 | +83% | 0 | 0 | — |
case-17 | fail→pass | 11,124 | 4,730 | -57% | 1 | 1 | 0% | 2,111 | 3,766 | +78% | 0 | 0 | — |
case-18 | pass→pass | 12,025 | 6,128 | -49% | 1 | 1 | 0% | 2,403 | 4,126 | +72% | 0 | 0 | — |
case-19 | fail→pass | 11,357 | 10,355 | -9% | 1 | 1 | 0% | 1,948 | 4,592 | +136% | 0 | 0 | — |
case-20 | pass→pass | 18,364 | 20,922 | +14% | 1 | 1 | 0% | 3,912 | 7,759 | +98% | 0 | 0 | — |
case-21 | pass→fail | 17,919 | 25,375 | +42% | 1 | 1 | 0% | 4,468 | 8,815 | +97% | 0 | 0 | — |
case-22 | pass→pass | 11,913 | 13,558 | +14% | 1 | 1 | 0% | 2,404 | 5,841 | +143% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +45 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.