agency-web/app/components/Cursor.tsx

68 lines
2.3 KiB
TypeScript

"use client";
/**
* Custom cursor: a spring-following dot + a larger ring.
* - Grows + shows a contextual label when hovering [data-cursor] targets.
* - Hidden on touch / coarse pointers and when prefers-reduced-motion is set
* (falls back to the native cursor, which globals.css restores).
* - Built with Motion springs for buttery follow without layout thrash
* (transform-only, GPU-friendly).
*/
import { useEffect, useState } from "react";
import { motion, useMotionValue, useSpring } from "motion/react";
export default function Cursor() {
const [enabled, setEnabled] = useState(false);
const [hovering, setHovering] = useState(false);
const [label, setLabel] = useState("");
const x = useMotionValue(-100);
const y = useMotionValue(-100);
const ringX = useSpring(x, { stiffness: 350, damping: 30, mass: 0.6 });
const ringY = useSpring(y, { stiffness: 350, damping: 30, mass: 0.6 });
const dotX = useSpring(x, { stiffness: 900, damping: 40 });
const dotY = useSpring(y, { stiffness: 900, damping: 40 });
useEffect(() => {
const fine = window.matchMedia("(pointer: fine)").matches;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!fine || reduce) return;
setEnabled(true);
document.documentElement.classList.add("has-custom-cursor");
const move = (e: PointerEvent) => {
x.set(e.clientX);
y.set(e.clientY);
const target = (e.target as HTMLElement)?.closest<HTMLElement>(
"[data-cursor], a, button"
);
if (target) {
setHovering(true);
setLabel(target.getAttribute("data-cursor") || "");
} else {
setHovering(false);
setLabel("");
}
};
window.addEventListener("pointermove", move, { passive: true });
return () => {
window.removeEventListener("pointermove", move);
document.documentElement.classList.remove("has-custom-cursor");
};
}, [x, y]);
if (!enabled) return null;
return (
<div aria-hidden="true" className="cursor-layer">
<motion.div
className={`cursor-ring ${hovering ? "is-hover" : ""} ${label ? "is-labelled" : ""}`}
style={{ x: ringX, y: ringY }}
>
{label && <span className="cursor-ring__label">{label}</span>}
</motion.div>
<motion.div className="cursor-dot" style={{ x: dotX, y: dotY }} />
</div>
);
}