projects/ui/ux/cursor invert
contents

elsewhere

tiktok

github

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.

React 19+Next.js 16+Tailwind v4

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.

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. 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

  1. Copy cursor-effect.tsx into src/components/.
  2. Add the .theme-inverted class to your globals.css (overriding the theme variables you want flipped).
  3. Wrap content: <CursorInvert>…</CursorInvert>

Built for Next.js 15+ with React 19 and Tailwind v4. Works in any React app with minor changes.

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.

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-invert