Cursor Reveal.
A small React component for a soft cursor-following reveal lens. Move your cursor anywhere on this page to see it work.
A circular lens follows the cursor and reveals the page content tinted, recoloured, or rendered in a different typeface underneath. The CTRLSZE home uses it. Free to copy, modify, or ignore.
"use client";
/**
* CursorReveal
* ------------
* Renders its children twice and reveals the second copy (optionally in a
* different typeface, optionally tinted) inside a soft circular lens that
* follows the cursor.
*
* Architecture in six points:
*
* 1) Two DOM copies, not a font-swap-in-region. CSS applies fonts per
* element, never per spatial region. Rendering twice keeps the text real
* — selectable, scalable, crawler-visible without cloaking risk.
*
* 2) The reveal layer is `aria-hidden` and `inert`. Screen readers and
* keyboard users never reach it. Crawlers see duplicate text but it is
* identical to the visible text and clearly decorative — not cloaking.
*
* 3) The lens is a CSS `mask-image: radial-gradient(...)` centred on two
* CSS custom properties (--lens-x, --lens-y) updated from JS. The mask
* is GPU-composited, so we pay one layer's cost, not a paint per frame.
*
* 4) Easing: lerp `current` toward `target` each rAF tick. The loop self-
* suspends when they converge so a still cursor costs nothing.
*
* 5) Positioning: the reveal layer is `position: fixed` covering the
* viewport (so the mask works in viewport coordinates, matching
* pointer.clientX/Y). The contents of the layer are translated by
* -scrollY so they scroll with the page, even though the layer itself
* is viewport-pinned.
*
* 6) Pause regions: any element with `data-cursor-reveal-pause` (or any
* ancestor with that attribute) suppresses the reveal while the cursor
* is over it. Useful for nav dropdowns, modals, tooltips.
*
* Tint: when set, every text descendant of the reveal layer is forced to
* the tint colour via a scoped <style> block with !important.
*
* Guards: the reveal layer renders only when (pointer: fine) matches and
* prefers-reduced-motion: reduce does not. SSR sends sans-only.
*/
import {
useEffect,
useId,
useRef,
useState,
type ReactNode,
type CSSProperties,
} from "react";
type CursorRevealProps = {
children: ReactNode;
radius?: number;
feather?: number;
ease?: number;
serifClassName?: string;
tint?: string;
};
export function CursorReveal({
children,
radius = 180,
feather = 0.35,
ease = 0.15,
serifClassName = "font-serif",
tint,
}: CursorRevealProps) {
const [enabled, setEnabled] = useState(false);
const reactId = useId();
const scopeId = `cursor-reveal-${reactId.replace(/:/g, "")}`;
const layerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fineMQ = window.matchMedia("(pointer: fine)");
const reduceMQ = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setEnabled(fineMQ.matches && !reduceMQ.matches);
update();
fineMQ.addEventListener("change", update);
reduceMQ.addEventListener("change", update);
return () => {
fineMQ.removeEventListener("change", update);
reduceMQ.removeEventListener("change", update);
};
}, []);
useEffect(() => {
if (!enabled) return;
const layer = layerRef.current;
if (!layer) return;
let targetX = -9999;
let targetY = -9999;
let currentX = -9999;
let currentY = -9999;
let rafId = 0;
let running = false;
let paused = false;
const tick = () => {
currentX += (targetX - currentX) * ease;
currentY += (targetY - currentY) * ease;
layer.style.setProperty("--lens-x", `${currentX}px`);
layer.style.setProperty("--lens-y", `${currentY}px`);
const dx = targetX - currentX;
const dy = targetY - currentY;
if (dx * dx + dy * dy < 0.25) {
running = false;
return;
}
rafId = requestAnimationFrame(tick);
};
const start = () => {
if (running) return;
running = true;
rafId = requestAnimationFrame(tick);
};
const isPaused = (x: number, y: number): boolean => {
const stack = document.elementsFromPoint(x, y);
for (const el of stack) {
if (layer.contains(el)) continue;
return !!el.closest(
"[data-cursor-reveal-pause], input, textarea, select, [contenteditable='true']"
);
}
return false;
};
const onMove = (e: PointerEvent) => {
const wasPaused = paused;
paused = isPaused(e.clientX, e.clientY);
if (paused) {
targetX = -9999;
targetY = -9999;
currentX = -9999;
currentY = -9999;
layer.style.setProperty("--lens-x", "-9999px");
layer.style.setProperty("--lens-y", "-9999px");
return;
}
if (wasPaused) {
currentX = e.clientX;
currentY = e.clientY;
}
targetX = e.clientX;
targetY = e.clientY;
start();
};
const onLeave = () => {
targetX = -9999;
targetY = -9999;
start();
};
window.addEventListener("pointermove", onMove, { passive: true });
window.addEventListener("pointerleave", onLeave);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerleave", onLeave);
cancelAnimationFrame(rafId);
};
}, [enabled, ease]);
useEffect(() => {
if (!enabled) return;
const content = contentRef.current;
if (!content) return;
let pending = false;
const apply = () => {
pending = false;
content.style.transform = `translate3d(0, ${-window.scrollY}px, 0)`;
};
const onScroll = () => {
if (pending) return;
pending = true;
requestAnimationFrame(apply);
};
apply();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [enabled]);
const inner = Math.max(0, radius * (1 - feather));
const outer = radius;
const maskValue = `radial-gradient(circle ${outer}px at var(--lens-x) var(--lens-y), #000 0, #000 ${inner}px, transparent ${outer}px)`;
const layerStyle: CSSProperties = {
WebkitMaskImage: maskValue,
maskImage: maskValue,
["--lens-x" as string]: "-9999px",
["--lens-y" as string]: "-9999px",
};
const tintStyleSheet = tint
? `[data-cursor-reveal-scope="${scopeId}"], [data-cursor-reveal-scope="${scopeId}"] * { color: ${tint} !important; }`
: null;
return (
<>
{children}
{enabled && (
<>
{tintStyleSheet && (
<style dangerouslySetInnerHTML={{ __html: tintStyleSheet }} />
)}
<div
ref={layerRef}
aria-hidden="true"
inert
className={`pointer-events-none fixed inset-0 z-[100] overflow-hidden ${serifClassName}`}
style={layerStyle}
>
<div
ref={contentRef}
data-cursor-reveal-scope={scopeId}
style={{ willChange: "transform" }}
>
{children}
</div>
</div>
</>
)}
</>
);
}Install
- Copy or download cursor-reveal.tsx into your src/components/ folder.
- Wrap the content you want to reveal: <CursorReveal tint="#0047ab">…</CursorReveal>
- That's it. Optional: pass serifClassName, radius, ease, or feather to tune.
Built for Next.js 15+ with React 19 and Tailwind v4. Works in any React app with minor changes — see the FAQ.
Two DOM copies, not a font swap
CSS applies fonts and colours per element, never per spatial region. The only ways to change what text looks like inside a region are to render the content twice, draw to canvas, or render to SVG with a clipPath. Rendering twice keeps the text real — selectable, scalable, crawler-visible.
The reveal layer is hidden from assistive tech
aria-hidden="true" plus the inert attribute removes the second copy from the accessibility tree and from keyboard tab order. Screen readers see one copy of the content. Crawlers see two, but they're identical and clearly decorative — not cloaking.
The lens is a CSS mask, not a clip
mask-image: radial-gradient(...) gives a soft-edged reveal that feels like a lens. The centre is driven by two CSS custom properties updated from JS. The mask is GPU-composited, so we pay one layer's cost regardless of cursor speed.
Easing self-suspends
Two coordinate pairs — target (raw cursor) and current (rendered) — lerp toward each other on each animation frame. When they converge to within sub-pixel distance, the loop stops. A still cursor costs nothing.
Fixed layer, scrolled contents
The reveal layer is position: fixed so the mask works in viewport coordinates. The contents inside the layer are translated by -scrollY on every scroll frame, so what's under the lens always matches what's actually on screen.
Will this work without Tailwind?
Yes, with one change. The component uses Tailwind utility classes for layout (pointer-events-none, fixed inset-0, etc.). Replace those with equivalent CSS or inline styles. The mask, easing, and pointer logic don't depend on Tailwind.
Does it work on mobile?
By design, no. The reveal layer renders only when (pointer: fine) matches — i.e. there's a real cursor. Touch users see only the original content, with no degraded experience. The page is fully functional without the effect.
Why doesn't it follow on a tablet stylus?
Stylus pointers register as (pointer: fine) on most devices, so it will follow. If you're on a tablet with a stylus and don't see it, the OS might be reporting (pointer: coarse) — a quirk of some Android devices with optional styluses. There's no good cross-device way around this.
Can I use a different font or colour?
Yes. Pass serifClassName="" to disable the default font swap, or set it to your own class. Pass tint="#yourcolor" or any CSS colour value to recolour the reveal layer. Both are optional and independent.
How does it handle long pages and scroll?
The reveal layer is position: fixed for the mask, but its inner content is translated by -window.scrollY on every scroll frame. So the lens follows the cursor regardless of scroll position, and the content under the lens always matches what's actually on screen.
What's the license?
Pending. Treat it as free for any use until I publish a formal license. If you're shipping it commercially and want certainty before then, send me a message.
Used on the CTRLSZE home. If you ship it somewhere I'd enjoy seeing, send me a link.
Provided as-is, with no warranty of any kind. You're responsible for testing this in your own project, ensuring it meets your accessibility, performance, and security requirements, and for any consequences of using it. CTRLSZE and AURACO PTY LTD accept no liability for damages, data loss, or issues arising from use, modification, or distribution of this code.
Built by Sze · License pending