Heart Cursor Reveal.

A heart-shaped cursor lens. Inside the heart, text turns hot pink.

React 19+Next.js 15+Tailwind v4

¶ 01 — What it is

A heart-shaped lens follows the cursor and recolours text underneath in hot pink. The shape is an SVG path you can swap for any other shape — star, flower, custom logo. Same DOM-clone architecture as the other cursor effects.

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

  1. Copy cursor-effect.tsx into src/components/.
  2. Wrap content: <HeartCursorReveal>…</HeartCursorReveal>
  3. Optional: pass tint="#yourcolor" to change the text colour, or radius={N} to change the heart size.

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

¶ 03 — How it works

5 decisions

Two DOM copies, masked by an SVG path

Same architecture as Cursor Reveal, but the mask is an SVG path (the heart) instead of a radial gradient (the circle). The path data lives at the top of the source file as a constant, swap-able for any other shape.

Hard edges on purpose

Other variants soft-feather the lens edge. The heart doesn't, because feathering hides the shape. With hard edges the heart is unmistakably a heart at any cursor position.

Tint forces colour onto every text descendant

An injected scoped stylesheet sets color: hot-pink !important on every text node inside the lens. The heart shape clips this so it only paints inside the lens. Outside, the page is normal.

Mask follows the cursor via CSS variables

The lens position is two CSS custom properties (--lens-x, --lens-y) updated from JS each animation frame. The browser recomposites the mask at GPU speed.

Easing self-suspends

Same lerp loop as the other variants. When the cursor stops, so does the loop. No idle CPU.

¶ 04 — FAQ

7 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 · heart