Web InnoventixFreeCode

Cursor Mask Effect

Original · free

A soft circular mask follows the cursor, revealing a hidden layer.

byWeb InnoventixReact + Tailwind
cursormaskimageeffect
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/imgfx-cursor-mask.json
imgfx-cursor-mask.tsx
"use client";

import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
import { useReducedMotion } from "motion/react";

export default function ImgfxCursorMask() {
  const reduce = useReducedMotion();

  const frameRef = useRef<HTMLDivElement | null>(null);
  const revealRef = useRef<HTMLDivElement | null>(null);
  const rafRef = useRef<number | null>(null);

  // Target (raw pointer) and current (lerped) positions, in 0..100 percent.
  const target = useRef({ x: 50, y: 50 });
  const current = useRef({ x: 50, y: 50 });

  const [active, setActive] = useState(false);
  const [ready, setReady] = useState(false);

  const applyVars = useCallback((x: number, y: number) => {
    const node = revealRef.current;
    if (!node) return;
    node.style.setProperty("--cm-x", `${x}%`);
    node.style.setProperty("--cm-y", `${y}%`);
  }, []);

  const tick = useCallback(() => {
    const c = current.current;
    const t = target.current;
    // Critically-damped-ish easing toward the pointer for a smooth trail.
    c.x += (t.x - c.x) * 0.18;
    c.y += (t.y - c.y) * 0.18;
    applyVars(c.x, c.y);
    rafRef.current = requestAnimationFrame(tick);
  }, [applyVars]);

  useEffect(() => {
    setReady(true);
    if (reduce) {
      // No trailing loop under reduced motion: pin the reveal to center.
      applyVars(50, 50);
      return;
    }
    rafRef.current = requestAnimationFrame(tick);
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    };
  }, [reduce, tick, applyVars]);

  const handleMove = useCallback(
    (e: PointerEvent<HTMLDivElement>) => {
      const frame = frameRef.current;
      if (!frame) return;
      const rect = frame.getBoundingClientRect();
      const x = ((e.clientX - rect.left) / rect.width) * 100;
      const y = ((e.clientY - rect.top) / rect.height) * 100;
      const cx = Math.max(0, Math.min(100, x));
      const cy = Math.max(0, Math.min(100, y));
      target.current = { x: cx, y: cy };
      if (reduce) {
        // Snap directly, no animation loop running.
        current.current = { x: cx, y: cy };
        applyVars(cx, cy);
      }
      setActive(true);
    },
    [reduce, applyVars],
  );

  const handleLeave = useCallback(() => {
    setActive(false);
    target.current = { x: 50, y: 50 };
    if (reduce) applyVars(50, 50);
  }, [reduce, applyVars]);

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-6 py-20 sm:py-24 md:px-10 dark:bg-neutral-950">
      <style>{`
        @keyframes cmask_halo {
          0%, 100% { opacity: .55; transform: scale(1); }
          50% { opacity: .9; transform: scale(1.06); }
        }
        @keyframes cmask_rise {
          from { opacity: 0; transform: translateY(16px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .cmask_rise { animation: cmask_rise .7s cubic-bezier(.22,1,.36,1) both; }
        .cmask_halo { animation: cmask_halo 3.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .cmask_rise, .cmask_halo { animation: none !important; }
        }
      `}</style>

      {/* ambient grid glow */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-[0.06] dark:opacity-[0.1]"
        style={{
          backgroundImage:
            "linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)",
          backgroundSize: "48px 48px",
          color: "#6366f1",
        }}
      />

      <div className="relative mx-auto max-w-5xl">
        <header className={ready && !reduce ? "cmask_rise mb-8 text-center" : "mb-8 text-center"}>
          <p className="text-xs font-semibold uppercase tracking-[0.28em] text-indigo-600 dark:text-indigo-400">
            Cursor Reveal
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl dark:text-neutral-50">
            A spotlight that follows your hand
          </h2>
          <p className="mx-auto mt-3 max-w-md text-sm text-neutral-500 dark:text-neutral-400">
            Move your cursor across the frame to wipe away the mist and uncover the color beneath.
          </p>
        </header>

        <div
          ref={frameRef}
          onPointerMove={handleMove}
          onPointerLeave={handleLeave}
          className="group relative aspect-[16/10] w-full cursor-none touch-none overflow-hidden rounded-2xl border border-neutral-200 bg-neutral-200 shadow-2xl shadow-neutral-900/10 ring-1 ring-black/5 dark:border-neutral-800 dark:bg-neutral-900 dark:shadow-black/40"
        >
          {/* BASE LAYER — the muted, hidden-until-revealed state */}
          <div className="absolute inset-0">
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src="/img/gallery/g07.webp"
              alt="Coastal cliffs at dusk, rendered in muted monochrome"
              loading="lazy"
              draggable={false}
              className="h-full w-full select-none object-cover brightness-90 grayscale contrast-125 transition duration-500 dark:brightness-[0.55]"
            />
            <div className="absolute inset-0 bg-neutral-100/55 mix-blend-luminosity dark:bg-neutral-950/55" />
          </div>

          {/* REVEAL LAYER — full color, unmasked by the cursor circle */}
          <div
            ref={revealRef}
            className="absolute inset-0 transition-opacity duration-500 ease-out"
            style={{
              opacity: active || reduce ? 1 : 0,
              ["--cm-x" as string]: "50%",
              ["--cm-y" as string]: "50%",
              WebkitMaskImage:
                "radial-gradient(circle 150px at var(--cm-x) var(--cm-y), #000 0%, #000 62%, transparent 100%)",
              maskImage:
                "radial-gradient(circle 150px at var(--cm-x) var(--cm-y), #000 0%, #000 62%, transparent 100%)",
            }}
          >
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src="/img/gallery/g07.webp"
              alt="The same coastal cliffs at dusk in vivid full color"
              loading="lazy"
              draggable={false}
              className="h-full w-full select-none object-cover saturate-125"
            />
            {/* crisp ring around the revealed area */}
            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-0"
              style={{
                background:
                  "radial-gradient(circle 152px at var(--cm-x) var(--cm-y), transparent 60%, rgba(99,102,241,0.35) 62%, transparent 64%)",
              }}
            />
          </div>

          {/* soft glow cursor indicator */}
          <div
            aria-hidden="true"
            className={active && !reduce ? "cmask_halo pointer-events-none absolute" : "pointer-events-none absolute opacity-0"}
            style={{
              left: "var(--cm-x, 50%)",
              top: "var(--cm-y, 50%)",
              width: 300,
              height: 300,
              transform: "translate(-50%, -50%)",
              background:
                "radial-gradient(circle, rgba(129,140,248,0.28) 0%, transparent 62%)",
            }}
          />

          {/* idle prompt when not hovering */}
          <div
            className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center transition-opacity duration-300"
            style={{ opacity: active ? 0 : 1 }}
          >
            <span className="rounded-full bg-white/85 px-4 py-1.5 text-xs font-medium text-neutral-700 shadow-sm backdrop-blur dark:bg-neutral-900/85 dark:text-neutral-200">
              Hover to reveal
            </span>
          </div>
        </div>

        <p className="mt-5 text-center text-xs text-neutral-400 dark:text-neutral-500">
          The reveal circle trails the pointer with easing for a soft, weighted feel.
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →