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.

React 19+Next.js 15+Tailwind v4

¶ 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

One file · paste or download
cursor-effect.tsx
"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.
 *    Caveat: a font swap on the cloned copy will drift from the
 *    original at the glyph level (different advance widths). Stick to
 *    fonts with similar metrics (Fraunces ≈ Geist Sans) or accept
 *    visible divergence. Colour, weight, theme variable swaps are
 *    metric-preserving and track perfectly.
 *
 * 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}
    />
  );
}

/**
 * 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).
 *
 * Caveat: any font swap changes glyph advance widths, so the cloned
 * copy's line-breaks will drift slightly from the original on long
 * paragraphs. Fraunces and Geist are close enough in metrics that the
 * effect reads as "same text, different face" rather than diverging.
 * Pass a wildly different font (e.g. Cedarville Cursive) and that
 * drift becomes obvious.
 */
export function CursorFontReveal({
  serifClassName = "font-serif",
  ...rest
}: WrapperProps) {
  return (
    <CursorEffect
      shape="circle"
      serifClassName={serifClassName}
      {...rest}
    />
  );
}

Install

  1. Copy or download cursor-effect.tsx into your src/components/ folder.
  2. 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.
  3. Wrap the content you want to swap: <CursorFontReveal>…</CursorFontReveal>
  4. 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 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 · font