Cursor Mask Reveal
A feathered hole that trails the pointer through one layer to expose a different one beneath, like a finished UI over its construction drawing. Drifts at rest, drag on touch, arrow keys on a keyboard. No dependencies.
DOM + CSS0 deps1 file · 11 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
const IDLE_X = 0.387; // idle path start (fraction of width) - matches the server-rendered mask
const IDLE_Y = 0.5;
const IDLE_SHARE = 0.85; // idle hole = this share of `radius`
const PRESS_GROW = 1.15;
const TOUCH_LIFT = 0.55; // touch: the hole sits this many radii above the finger
const KEY_STEP = 32;
type Mode = "idle" | "pointer" | "key";
export function CursorMaskReveal({
base,
top,
radius = 72,
feather = 30,
showRing = true,
lag = 0.1,
idle = false,
mode = "reveal",
label = "Interactive reveal. Move the pointer, drag, or use the arrow keys to uncover the layer beneath.",
className,
style,
}: {
/** The layer revealed under the pointer. */
base: ReactNode;
/** The layer the pointer punches a hole through. */
top: ReactNode;
/** Hole radius in px. */
radius?: number;
/** 0-100. How soft the edge of the hole is. */
feather?: number;
/** Outline the hole so its edge is legible over busy content. */
showRing?: boolean;
/** Seconds the hole trails the pointer (0 = glued to it). */
lag?: number;
/** Park a slowly drifting circle when nothing is pointing at the box, so the effect shows at rest. */
idle?: boolean;
/** "reveal" (default): the top layer shows only inside the circle and is whole at rest. "hole": the circle is cut out of the top layer to expose the base. */
mode?: "reveal" | "hole";
/** Accessible name of the region. */
label?: string;
className?: string;
style?: CSSProperties;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLSpanElement>(null);
const opts = useRef({ radius, lag, idle });
const wakeRef = useRef<() => void>(() => {});
const f = Math.min(100, Math.max(0, feather));
const r0 = idle ? Math.round(radius * IDLE_SHARE) : 0;
const stop = (1 - f / 100).toFixed(3);
const at = "circle var(--cmr-r) at var(--cmr-x) var(--cmr-y)";
const mask =
mode === "hole"
? "radial-gradient(" + at + ", transparent calc(var(--cmr-r) * " + stop + "), #000 var(--cmr-r))"
: "radial-gradient(" + at + ", #000 calc(var(--cmr-r) * " + stop + "), transparent var(--cmr-r)), linear-gradient(rgba(0,0,0,var(--cmr-a)), rgba(0,0,0,var(--cmr-a)))";
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
let reduced = mq.matches;
let W = root.offsetWidth;
let H = root.offsetHeight;
const s = {
x: W * IDLE_X,
y: H * IDLE_Y,
r: opts.current.idle ? opts.current.radius * IDLE_SHARE : 0,
tx: W * IDLE_X,
ty: H * IDLE_Y,
mode: "idle" as Mode,
pressed: false,
touch: false,
vis: true,
raf: 0,
last: 0,
t0: -1,
ready: W > 0,
};
const targets = (now: number) => {
const o = opts.current;
if (s.mode !== "idle") {
const k = s.pressed ? PRESS_GROW : 1;
return { x: s.tx, y: s.ty, r: o.radius * k };
}
if (!o.idle) return { x: s.x, y: s.y, r: 0 };
if (reduced) return { x: W * IDLE_X, y: H * IDLE_Y, r: o.radius * IDLE_SHARE };
if (s.t0 < 0) s.t0 = now;
const t = (now - s.t0) / 1000;
return {
x: W * (0.5 + 0.2 * Math.sin(t * 0.5 - 0.6)),
y: H * (0.5 + 0.16 * Math.sin(t * 0.37)),
r: o.radius * IDLE_SHARE,
};
};
const paint = () => {
const o = opts.current;
root.style.setProperty("--cmr-x", s.x.toFixed(1) + "px");
root.style.setProperty("--cmr-y", s.y.toFixed(1) + "px");
root.style.setProperty("--cmr-r", Math.max(0, s.r).toFixed(1) + "px");
root.style.setProperty("--cmr-a", Math.max(0, 1 - s.r / Math.max(1, o.radius * 0.6)).toFixed(3));
const ring = ringRef.current;
if (ring) {
const k = o.radius > 0 ? s.r / o.radius : 0;
ring.style.opacity = String(Math.min(1, Math.max(0, k * 1.6)));
ring.style.transform =
"translate3d(" + (s.x - o.radius).toFixed(1) + "px," + (s.y - o.radius).toFixed(1) + "px,0) scale(" + Math.max(0.001, k).toFixed(3) + ")";
}
};
const frame = (now: number) => {
s.raf = 0;
const dt = s.last ? Math.min(0.05, (now - s.last) / 1000) : 1 / 60;
s.last = now;
const g = targets(now);
const o = opts.current;
const aPos = reduced || o.lag <= 0 ? 1 : 1 - Math.exp(-dt / Math.max(0.005, o.lag));
const aRad = reduced ? 1 : 1 - Math.exp(-dt / 0.085);
s.x += (g.x - s.x) * aPos;
s.y += (g.y - s.y) * aPos;
s.r += (g.r - s.r) * aRad;
const drifting = s.mode === "idle" && o.idle && !reduced;
const settled = Math.abs(g.x - s.x) < 0.1 && Math.abs(g.y - s.y) < 0.1 && Math.abs(g.r - s.r) < 0.1;
if (settled && !drifting) {
s.x = g.x;
s.y = g.y;
s.r = g.r;
paint();
s.last = 0;
return;
}
paint();
if (s.vis && !document.hidden) s.raf = requestAnimationFrame(frame);
else s.last = 0;
};
const wake = () => {
if (!s.raf && s.vis && !document.hidden) s.raf = requestAnimationFrame(frame);
};
wakeRef.current = wake;
const place = (e: PointerEvent) => {
const rect = root.getBoundingClientRect();
const kx = rect.width ? root.offsetWidth / rect.width : 1;
const ky = rect.height ? root.offsetHeight / rect.height : 1;
s.tx = (e.clientX - rect.left) * kx;
s.ty = (e.clientY - rect.top) * ky - (s.touch ? opts.current.radius * TOUCH_LIFT : 0);
};
const onEnter = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
s.touch = false;
s.mode = "pointer";
place(e);
wake();
};
const onMove = (e: PointerEvent) => {
if (e.pointerType === "touch" && !s.pressed) return;
s.touch = e.pointerType === "touch";
s.mode = "pointer";
place(e);
wake();
};
const onDown = (e: PointerEvent) => {
s.touch = e.pointerType === "touch";
s.pressed = true;
s.mode = "pointer";
place(e);
wake();
};
const onUp = (e: PointerEvent) => {
s.pressed = false;
if (e.pointerType === "touch") s.mode = "idle";
wake();
};
const onLeave = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
s.pressed = false;
if (s.mode === "pointer") s.mode = "idle";
wake();
};
const onKey = (e: KeyboardEvent) => {
if (e.target !== root) return;
const step = e.shiftKey ? KEY_STEP * 3 : KEY_STEP;
let dx = 0;
let dy = 0;
if (e.key === "ArrowLeft") dx = -step;
else if (e.key === "ArrowRight") dx = step;
else if (e.key === "ArrowUp") dy = -step;
else if (e.key === "ArrowDown") dy = step;
else if (e.key === "Escape" && s.mode === "key") {
s.mode = "idle";
wake();
return;
} else return;
e.preventDefault();
if (s.mode !== "key") {
s.mode = "key";
s.tx = s.x;
s.ty = s.y;
}
s.tx = Math.min(W, Math.max(0, s.tx + dx));
s.ty = Math.min(H, Math.max(0, s.ty + dy));
wake();
};
const onFocus = () => {
if (!root.matches(":focus-visible") || s.mode === "pointer") return;
s.mode = "key";
s.tx = s.x;
s.ty = s.y;
wake();
};
const onBlur = () => {
if (s.mode === "key") s.mode = "idle";
wake();
};
const ro = new ResizeObserver(() => {
W = root.offsetWidth;
H = root.offsetHeight;
if (!s.ready && W > 0) {
s.ready = true;
s.x = s.tx = W * IDLE_X;
s.y = s.ty = H * IDLE_Y;
}
s.tx = Math.min(W, s.tx);
s.ty = Math.min(H, s.ty);
wake();
});
ro.observe(root);
const io = new IntersectionObserver(
(en) => {
s.vis = en.some((x) => x.isIntersecting);
if (s.vis) wake();
},
{ rootMargin: "200px" },
);
io.observe(root);
const onVis = () => wake();
const onMq = () => {
reduced = mq.matches;
wake();
};
document.addEventListener("visibilitychange", onVis);
mq.addEventListener("change", onMq);
root.addEventListener("pointerenter", onEnter);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerdown", onDown);
root.addEventListener("pointerup", onUp);
root.addEventListener("pointercancel", onUp);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("keydown", onKey);
root.addEventListener("focus", onFocus);
root.addEventListener("blur", onBlur);
paint();
wake();
return () => {
cancelAnimationFrame(s.raf);
s.raf = 0;
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
mq.removeEventListener("change", onMq);
root.removeEventListener("pointerenter", onEnter);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerdown", onDown);
root.removeEventListener("pointerup", onUp);
root.removeEventListener("pointercancel", onUp);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("keydown", onKey);
root.removeEventListener("focus", onFocus);
root.removeEventListener("blur", onBlur);
};
}, []);
useEffect(() => {
opts.current = { radius, lag, idle };
wakeRef.current();
}, [radius, lag, idle]);
return (
<div
ref={rootRef}
role="group"
aria-label={label}
tabIndex={0}
className={"group relative touch-pan-y overflow-hidden outline-none focus-visible:!outline-0 " + (className ?? "")}
style={
{
"--cmr-x": (IDLE_X * 100).toFixed(1) + "%",
"--cmr-y": IDLE_Y * 100 + "%",
"--cmr-r": r0 + "px",
"--cmr-a": Math.max(0, 1 - r0 / Math.max(1, radius * 0.6)).toFixed(3),
...style,
} as CSSProperties
}
>
<div className="absolute inset-0">{base}</div>
{/* The top layer carries a real CSS mask, so the base can be any markup. The engine writes only
the --cmr-* custom properties; the mask string itself never changes per frame. */}
<div className="absolute inset-0" style={{ maskImage: mask, WebkitMaskImage: mask }}>
{top}
</div>
{showRing && (
<span
ref={ringRef}
aria-hidden
className="pointer-events-none absolute left-0 top-0 rounded-full opacity-0 will-change-transform"
style={{
width: radius * 2,
height: radius * 2,
boxShadow:
"inset 0 0 0 1px rgba(255,255,255,0.62), 0 0 0 1px rgba(0,0,0,0.3), inset 0 0 26px rgba(255,255,255,0.12), 0 18px 40px -12px rgba(0,0,0,0.5)",
}}
/>
)}
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 ring-2 ring-inset ring-sky-400 transition-opacity duration-150 group-focus-visible:opacity-100"
/>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this pattern
A spotlight that merely brightens the area under the cursor is a radial gradient with some opacity, so it can only change the lighting. Masking the top layer (in hole mode, removing it where the pointer is) means what shows through is arbitrary markup: a construction drawing under a finished screen, a photograph under a duotone, an answer under a question. The mask is a real CSS radial-gradient (with the -webkit- prefix for Safari) whose centre and radius are three custom properties, --cmr-x, --cmr-y and --cmr-r. One requestAnimationFrame loop writes them straight to the DOM, so React never renders per pixel. The hole trails the pointer with an exponential ease (lag, 100 ms by default), swells about 15 percent while pressed, and eases open and shut as the pointer enters and leaves. With nothing pointing at it the hole parks and drifts on two slow sines (idle), so the effect is visible at rest; idle is off by default, so the top layer stays whole until hover. Two modes share the same mask: reveal (the default, and the original behaviour) shows the top layer only inside the circle and keeps it whole at rest, while hole cuts the circle out of the top layer to expose the base, as in the demo. Feather sets how much of the radius is a soft edge, from a hard porthole at 0 to a vignette at 100, and the ring has a dark and a light outline so it reads on any content. The loop sleeps when the box is off-screen, the tab is hidden or everything has settled. Both layers fill the box, so give it a size with className (an aspect ratio works well) and keep anything essential outside the hidden layer.
Curator’s note
Cursor Mask Reveal, rebuilt in October: the same real-mask idea, now with a trailing hole, a drifting idle state and a way in for touch and keyboard.
