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→