Cursor Invert.
A circular cursor lens that flips the page theme inside the radius. Move your cursor over this page to see the cream and ink swap places.
¶ 01 — What it is
A circular lens follows the cursor and inverts the page theme inside it — cream paper becomes near-black, ink text becomes paper-cream. The accent and rule colours adjust to read against the new background. Layout, fonts, and spacing stay unchanged.
¶ 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. Three
* thin wrappers (`CursorReveal`, `CursorInvert`, `HeartCursorReveal`)
* 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, not a font-swap-in-region. CSS applies fonts and
* colours per element, never per spatial region. Rendering twice
* keeps the text real — selectable, scalable, crawler-visible.
*
* 2) The lens layer is `aria-hidden` and `inert`. Screen readers and
* keyboard users never reach it.
*
* 3) The lens shape is a CSS mask: a radial gradient for circles, an
* SVG path for hearts. The mask is GPU-composited.
*
* 4) Easing self-suspends when target == current.
*
* 5) The layer is `position: fixed`; its inner content is translated by
* -scrollY so the lens stays in register with the page.
*
* 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.
// To change the shape, replace this path with another SVG `d` attribute.
// To add a new shape (e.g. star), add a case below in `getMaskValue`.
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 layerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const childrenRootRef = useRef<HTMLSpanElement>(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 });
let observer: ResizeObserver | null = null;
const childrenRoot = childrenRootRef.current;
if (childrenRoot && typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(onScroll);
observer.observe(childrenRoot);
observer.observe(document.body);
}
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
observer?.disconnect();
};
}, [enabled]);
// === SHAPE ===
// Build the mask value for the chosen shape. Circle uses a radial
// gradient (with optional soft feather); heart uses an SVG path.
let maskValue: string;
if (shape === "heart") {
const half = radius / 2;
const heightPx = (radius * HEART_VIEW_H) / HEART_VIEW_W;
const halfH = heightPx / 2;
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}")`;
// For heart, the mask is positioned via CSS background-position-style
// properties (mask-position). We need an extra style override.
const _half = half;
const _halfH = halfH;
void _half;
void _halfH;
} 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",
};
// Fill colour painted behind the lens content. With the mask, this
// wash only paints inside the lens silhouette.
const fillStyle: CSSProperties = fillColor
? { background: fillColor }
: {};
// === TINT INJECTION ===
// Force the chosen colour onto every text descendant of the lens.
// To change *how* the tint applies (only headings, mix-blend-mode,
// per-element data attribute), edit the rule below.
//
// When fillColor is set (e.g. heart's pink wash), also clear background
// colours from cloned descendants so the layer's fill shows through.
// Otherwise cloned <section bg-paper> elements paint cream over it.
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 (
<>
<span
ref={childrenRootRef}
style={{ display: "contents" }}
>
{children}
</span>
{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 fixed inset-0 z-[100] overflow-hidden ${serifClassName}`}
style={{ ...layerStyle, ...fillStyle }}
>
<div
ref={contentRef}
data-cursor-effect-scope={scopeId}
className={invertTheme ? "theme-inverted" : ""}
style={{ willChange: "transform" }}
>
{children}
</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}
/>
);
}Install
- Copy cursor-effect.tsx into src/components/.
- Add the .theme-inverted class to your globals.css (overriding the theme variables you want flipped).
- Wrap content: <CursorInvert>…</CursorInvert>
Built for Next.js 15+ with React 19 and Tailwind v4. Works in any React app with minor changes.
¶ 03 — How it works
Theme variables, not pixel inversion
The lens content gets a CSS class that overrides --color-paper, --color-ink, and friends. Every descendant inherits the new variable values via CSS variable inheritance. Images and illustrations stay as-is; only design-system colours flip.
Two DOM copies
Same architecture as Cursor Reveal — children rendered twice, the second copy lives inside a fixed-position lens layer. Inverting works on the clone tree only; the visible page is unaffected.
The reveal layer is hidden from assistive tech
aria-hidden="true" plus the inert attribute removes the second copy from the accessibility tree. Screen readers see one copy.
Easing self-suspends
Two coordinate pairs lerp toward each other each animation frame. When they converge, the loop stops. A still cursor costs nothing.
Fixed layer, scrolled contents
The layer is position: fixed so the mask works in viewport coordinates. Contents are translated by -scrollY, and a ResizeObserver re-syncs on layout changes.
¶ 04 — FAQ
¶ 05 — Notes
See more cursors on the UI Cursors 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 · invert