Skip to content
ARDENT / MOTION LAB
Library

Microinteractions · Intermediate

Blend-Mode Cursor

A spring-smoothed dot follows the pointer within its region and scales up over interactive targets, inverting what sits beneath.

Live preview

Move a mouse inside the stage and over the rows.

Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
 * Cursor Follow
 * A scoped spring cursor expands over an editorial navigation.
 * Requires: motion, Tailwind with shadcn semantic tokens.
 */
import { useEffect, useState } from "react";
import { motion, useReducedMotion, useSpring } from "motion/react";

export default function CursorFollow({
  reduced: reducedProp,
  intensity = 1,
}: {
  reduced?: boolean;
  intensity?: number;
}) {
  const systemReduced = useReducedMotion();
  const reduced = reducedProp ?? systemReduced ?? false;
  const amount = Math.min(1.5, Math.max(0.5, intensity));
  const [fine, setFine] = useState(false);
  const [inside, setInside] = useState(false);
  const [hovered, setHovered] = useState(false);
  const [selected, setSelected] = useState("Studio");
  const sx = useSpring(0, { stiffness: 350, damping: 32 });
  const sy = useSpring(0, { stiffness: 350, damping: 32 });
  const enabled = fine && !reduced;
  const visible = enabled && inside;

  useEffect(() => {
    const query = window.matchMedia("(hover: hover) and (pointer: fine)");
    const update = () => setFine(query.matches);
    update();
    query.addEventListener("change", update);
    return () => query.removeEventListener("change", update);
  }, []);

  return (
    <section
      className="relative flex min-h-[80vh] items-center justify-center overflow-hidden bg-background px-6 py-20 text-foreground"
      style={{ cursor: visible ? "none" : undefined }}
      onPointerLeave={() => {
        setInside(false);
        setHovered(false);
      }}
      onPointerCancel={() => setInside(false)}
      onPointerMove={(event) => {
        if (!enabled || event.pointerType !== "mouse") return;
        const rect = event.currentTarget.getBoundingClientRect();
        const nextX = event.clientX - rect.left;
        const nextY = event.clientY - rect.top;
        if (!inside) {
          sx.jump(nextX);
          sy.jump(nextY);
        } else {
          sx.set(nextX);
          sy.set(nextY);
        }
        setInside(true);
      }}
    >
      <div className="w-full max-w-5xl">
        <p className="mb-10 font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
          Follow your curiosity / 05
        </p>
        {["Reel", "Studio", "Journal"].map((label, index) => (
          <button
            key={label}
            type="button"
            onClick={() => setSelected(label)}
            onPointerEnter={() => setHovered(true)}
            onPointerLeave={() => setHovered(false)}
            aria-pressed={selected === label}
            style={{ cursor: visible ? "none" : undefined }}
            className="flex w-full items-center justify-between border-b border-border py-5 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4"
          >
            <span className="font-serif text-5xl tracking-tight sm:text-7xl">{label}</span>
            <span className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
              0{index + 1}
            </span>
          </button>
        ))}
        <p aria-live="polite" className="mt-8 text-sm text-muted-foreground">
          Exploring: {selected}
        </p>
      </div>
      {visible && (
        <motion.div
          aria-hidden="true"
          className="pointer-events-none absolute left-0 top-0 z-10 rounded-full bg-foreground mix-blend-difference"
          style={{ x: sx, y: sy, translateX: "-50%", translateY: "-50%" }}
          animate={{ width: (hovered ? 78 : 12) * amount, height: (hovered ? 78 : 12) * amount }}
          transition={{ duration: 0.25 }}
        />
      )}
    </section>
  );
}
Next patternEditorial Marquee→