Cursor Font Reveal.
A cursor-following lens that swaps the typeface underneath. Move your cursor anywhere on this page to see it work — body text becomes Fraunces inside the lens.
¶ 01 — what it is
Cursor Font Reveal is a free React component that renders a soft circular lens following the cursor. Inside the lens, body text is rendered in a different typeface — Fraunces by default — while the page below the lens stays in its original font. The original DOM copy sets the layout, so the page never reflows when the font swaps. Pass any Tailwind font utility via serifClassName to swap to a different face. Free to copy, modify, or ignore.
¶ 02 — get the code
"use client";
/**
* Cursor effects
* --------------
* One base component (`CursorEffect`) that renders its children twice
* and reveals the second copy inside a cursor-following lens. Thin
* wrappers (`CursorReveal`, `CursorInvert`, `HeartCursorReveal`,
* `CursorFontReveal`) preset different combinations of:
* - shape (circle | heart)
* - tint (any CSS colour, applied to text inside the lens)
* - serifClassName (Tailwind font utility, applied to the lens layer)
* - invertTheme (toggles a CSS class that flips theme variables)
*
* Architecture:
*
* 1) Two DOM copies inside the same relatively-positioned wrapper. The
* visible copy sits in normal flow; the lensed copy is an absolute
* overlay sized to its parent. Because both copies share the same
* layout box, every line, glyph, and break aligns — that's what the
* earlier viewport-fixed-overlay implementation got wrong.
*
* 2) Pointer coordinates are converted from viewport space (clientX/Y)
* to local space relative to the wrapper, so the mask follows the
* cursor regardless of page scroll.
*
* 3) The lens layer is `aria-hidden` and `inert`. Screen readers and
* keyboard users never reach it.
*
* 4) The lens shape is a CSS mask: a radial gradient for circles, an
* SVG path for hearts. The mask is GPU-composited.
*
* 5) Easing self-suspends when target == current.
*
* 6) Pause regions: `data-cursor-reveal-pause` on any element (or any
* ancestor) suppresses the lens while the cursor is over it.
*
* Customisation hooks (referenced by project pages):
* - // === TINT INJECTION === scoped tint stylesheet
* - // === FONT APPLICATION === serifClassName applied to layer
* - // === SHAPE === clip-path / mask choice
*
* Note: <details> can desync between the two copies because <details>
* stores `open` on the DOM element. If a page uses controlled FAQ
* components (open state lifted above CursorEffect), they stay in sync.
* See FaqItem in src/components/projects/faq-item.tsx for the pattern.
*/
import {
useEffect,
useId,
useRef,
useState,
type ReactNode,
type CSSProperties,
} from "react";
type Shape = "circle" | "heart";
type CursorEffectProps = {
children: ReactNode;
/** Lens radius in px (circle) or width in px (heart). */
radius?: number;
/** Soft edge for circle only; ignored for heart. 0–1. */
feather?: number;
/** Easing toward the cursor. 0–1; higher = snappier. */
ease?: number;
/** Tailwind font class applied to the lens layer. */
serifClassName?: string;
/** CSS colour string forced on every text descendant of the lens. */
tint?: string;
/** Lens shape. */
shape?: Shape;
/** Add `theme-inverted` class to the lens content so theme variables flip. */
invertTheme?: boolean;
/** Background colour applied behind the lens content. With a mask,
* this paints a wash of colour inside the lens silhouette so the
* shape is visible even where there's no text underneath. */
fillColor?: string;
};
// === SHAPE ===
// Heart-shaped clip-path. Coordinates are tuned for a 360x320 viewBox.
const HEART_PATH =
"M180 290 C 60 200, 20 130, 60 70 C 100 20, 160 35, 180 80 C 200 35, 260 20, 300 70 C 340 130, 300 200, 180 290 Z";
const HEART_VIEW_W = 360;
const HEART_VIEW_H = 320;
export function CursorEffect({
children,
radius = 180,
feather = 0.35,
ease = 0.15,
serifClassName = "",
tint,
shape = "circle",
invertTheme = false,
fillColor,
}: CursorEffectProps) {
const [enabled, setEnabled] = useState(false);
const reactId = useId();
const scopeId = `cursor-effect-${reactId.replace(/:/g, "")}`;
const wrapperRef = useRef<HTMLDivElement>(null);
const layerRef = 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 wrapper = wrapperRef.current;
const layer = layerRef.current;
if (!wrapper || !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 = (clientX: number, clientY: number): boolean => {
const stack = document.elementsFromPoint(clientX, clientY);
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 rect = wrapper.getBoundingClientRect();
// Outside the wrapper → treat as "left" so the lens slides away.
const insideX = e.clientX >= rect.left && e.clientX <= rect.right;
const insideY = e.clientY >= rect.top && e.clientY <= rect.bottom;
if (!insideX || !insideY) {
targetX = -9999;
targetY = -9999;
start();
return;
}
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;
}
const localX = e.clientX - rect.left;
const localY = e.clientY - rect.top;
if (wasPaused) {
currentX = localX;
currentY = localY;
}
targetX = localX;
targetY = localY;
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]);
// === SHAPE ===
let maskValue: string;
if (shape === "heart") {
const heightPx = (radius * HEART_VIEW_H) / HEART_VIEW_W;
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${HEART_VIEW_W} ${HEART_VIEW_H}' width='${radius}' height='${heightPx}'><path d='${HEART_PATH}' fill='black'/></svg>`;
const encoded = encodeURIComponent(svg).replace(/'/g, "%27");
maskValue = `url("data:image/svg+xml,${encoded}")`;
} else {
const inner = Math.max(0, radius * (1 - feather));
const outer = radius;
maskValue = `radial-gradient(circle ${outer}px at var(--lens-x) var(--lens-y), #000 0, #000 ${inner}px, transparent ${outer}px)`;
}
const layerStyle: CSSProperties =
shape === "heart"
? {
WebkitMaskImage: maskValue,
maskImage: maskValue,
WebkitMaskRepeat: "no-repeat",
maskRepeat: "no-repeat",
WebkitMaskPosition: `calc(var(--lens-x) - ${radius / 2}px) calc(var(--lens-y) - ${
((radius * HEART_VIEW_H) / HEART_VIEW_W) / 2
}px)`,
maskPosition: `calc(var(--lens-x) - ${radius / 2}px) calc(var(--lens-y) - ${
((radius * HEART_VIEW_H) / HEART_VIEW_W) / 2
}px)`,
["--lens-x" as string]: "-9999px",
["--lens-y" as string]: "-9999px",
}
: {
WebkitMaskImage: maskValue,
maskImage: maskValue,
["--lens-x" as string]: "-9999px",
["--lens-y" as string]: "-9999px",
};
const fillStyle: CSSProperties = fillColor ? { background: fillColor } : {};
// === TINT INJECTION ===
const tintStyleSheet = `
${
tint
? `[data-cursor-effect-scope="${scopeId}"], [data-cursor-effect-scope="${scopeId}"] * { color: ${tint} !important; }`
: ""
}
${
fillColor
? `[data-cursor-effect-scope="${scopeId}"] *:not([data-cursor-reveal-pause]):not([data-cursor-reveal-pause] *) { background: transparent !important; }`
: ""
}
`.trim();
return (
// `display: flow-root` establishes a new block formatting context so
// the visible children's first-child top margin doesn't escape the
// wrapper. Without it, the visible side would render with margin
// collapsed out of the wrapper while the cloned side (positioned-
// absolute layer is its own BFC) wouldn't — the two would sit at
// different y-offsets and the cursor lens would show ghost
// duplication. The structurally-matched inner div keeps the rest of
// the layout identical between copies.
<div
ref={wrapperRef}
style={{ position: "relative", display: "flow-root" }}
>
<div>{children}</div>
{enabled && (
<>
{tintStyleSheet && (
<style dangerouslySetInnerHTML={{ __html: tintStyleSheet }} />
)}
<div
ref={layerRef}
aria-hidden="true"
inert
// === FONT APPLICATION ===
// serifClassName is concatenated into the layer's className so
// every descendant inherits the font.
className={`pointer-events-none absolute inset-0 overflow-hidden ${serifClassName}`}
style={{ ...layerStyle, ...fillStyle }}
>
<div
data-cursor-effect-scope={scopeId}
className={invertTheme ? "theme-inverted" : ""}
>
{children}
</div>
</div>
</>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────
// Wrappers
// ─────────────────────────────────────────────────────────────────────
type WrapperProps = Omit<CursorEffectProps, "shape" | "invertTheme" | "tint"> & {
/** Override the wrapper's preset tint. */
tint?: string;
};
/**
* CursorReveal — soft circular lens. Default tint accent-blue, default
* font swap to serif. Used on the home page and the Reveal project page.
*/
export function CursorReveal({
tint = "var(--color-accent)",
serifClassName = "",
...rest
}: WrapperProps) {
return (
<CursorEffect
shape="circle"
tint={tint}
serifClassName={serifClassName}
{...rest}
/>
);
}
/**
* CursorInvert — soft circular lens that flips the page theme inside.
* No tint (the inverted theme handles colour). Sans-serif preserved.
*/
export function CursorInvert({
serifClassName = "",
...rest
}: WrapperProps) {
return (
<CursorEffect
shape="circle"
invertTheme
serifClassName={serifClassName}
{...rest}
/>
);
}
/**
* HeartCursorReveal — heart-shaped lens. Inside the heart, text turns
* hot pink and the area takes a faint pink wash so the heart shape is
* visible against the page background.
*/
export function HeartCursorReveal({
tint = "#ff1493",
serifClassName = "",
...rest
}: WrapperProps) {
return (
<CursorEffect
shape="heart"
radius={360}
tint={tint}
serifClassName={serifClassName}
fillColor="rgba(255, 20, 147, 0.12)"
{...rest}
/>
);
}
/**
* CursorFontReveal — soft circular lens that swaps body text under the
* cursor for a different typeface. Defaults to Fraunces (the page's
* existing serif via the font-serif Tailwind utility).
*
* Unlike the tint/invert variants, a font swap changes glyph shapes —
* cloned and visible glyphs don't pixel-align, so without an opaque
* fill both typefaces would paint through each other. The lens fills
* with the page's paper colour so the visible copy is occluded inside
* the lens and only the cloned (serif) copy reads.
*
* Caveat: font swaps change advance widths, so cloned line-breaks drift
* slightly from the original on long paragraphs. Fraunces and Geist are
* close enough that the effect reads as "same text, different face"
* rather than diverging.
*/
export function CursorFontReveal({
serifClassName = "font-serif",
...rest
}: WrapperProps) {
return (
<CursorEffect
shape="circle"
serifClassName={serifClassName}
fillColor="var(--color-paper)"
{...rest}
/>
);
}
install
- Copy or download cursor-effect.tsx into your src/components/ folder.
- Make sure your font-serif Tailwind utility points at the typeface you want inside the lens. In Tailwind v4, that's a --font-serif entry inside your @theme block in globals.css.
- Wrap the content you want to swap: <CursorFontReveal>…</CursorFontReveal>
- Optional: pass serifClassName to swap to a different font utility (e.g. font-mono), radius, ease, or feather to tune the lens.
Built for Next.js 15+ with React 19 and Tailwind v4. Works in any React app with minor changes — bring your own font loader and Tailwind utility for the lens typeface.
¶ 03 — how it works · 5 decisions
Font swap by region, not by reflow
CSS applies fonts per element, never per spatial region. To change what text looks like inside a circle without reflowing the page, you render the content twice and apply the font to one copy. The original copy keeps the layout box; the cursor-layer copy fills that same parent box with serif glyphs. The page below the cursor never moves — same insight as the original Cursor Reveal variant, applied to typeface instead of colour.
Fraunces over Cedarville Cursive
Initial draft used Cedarville Cursive for a handwriting effect. The cloned cursive copy diverged so far from the original Geist Sans positions that the two visibly didn't track — words appeared mid-line where the original said something else. Fraunces' advance widths are close enough to Geist Sans that the effect reads as one continuous text in a different face. The trade-off is honest: any font with materially different metrics will drift; Fraunces is close enough that it doesn't.
font-serif maps to Fraunces in @theme
Tailwind v4's @theme block in globals.css already declares --font-serif: var(--font-fraunces), Georgia, serif. The CursorFontReveal wrapper uses font-serif by default with no new tokens. Swap the serif face by changing one line in @theme.
Lens line-breaks differ from the original — accepted
Fraunces wraps slightly differently from Geist Sans on long paragraphs, so individual line-end words inside the lens won't always match the original below. Pixel-perfect alignment would require canvas-based per-character positioning, which loses real DOM, real selection, and real accessibility. The DOM-clone trade-off keeps everything real and lets crawlers see actual text — worth a few pixels of line-break drift.
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 it stays smooth at 60fps.
¶ 04 — faq · 8 questions
¶ 05 — notes
see more cursors on the ui/ux hub. if you ship one of these 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 · cursor-font