84 lines
3 KiB
TypeScript
84 lines
3 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Custom cursor — spring-following dot + trailing ring, the page's "alive" tell.
|
|
* - Ring trails with visible momentum (soft spring); dot leads (tighter spring)
|
|
* so the two have a believable lead/chase relationship — never a rigid pair.
|
|
* - On [data-cursor] / links / buttons the ring grows + shows a contextual
|
|
* label.
|
|
* - On press (pointerdown anywhere) the ring dips to scale(0.82) for instant
|
|
* tactile feedback — the same press language as the buttons.
|
|
* - Hidden on touch / coarse pointers and with prefers-reduced-motion
|
|
* (globals.css restores the native cursor).
|
|
*/
|
|
import { useEffect, useState } from "react";
|
|
import { motion, useMotionValue, useSpring } from "motion/react";
|
|
import { SPRING } from "./motion";
|
|
|
|
export default function Cursor() {
|
|
const [enabled, setEnabled] = useState(false);
|
|
const [hovering, setHovering] = useState(false);
|
|
const [label, setLabel] = useState("");
|
|
const [pressed, setPressed] = useState(false);
|
|
|
|
const x = useMotionValue(-100);
|
|
const y = useMotionValue(-100);
|
|
const ringX = useSpring(x, SPRING.cursorRing);
|
|
const ringY = useSpring(y, SPRING.cursorRing);
|
|
const dotX = useSpring(x, SPRING.cursorDot);
|
|
const dotY = useSpring(y, SPRING.cursorDot);
|
|
|
|
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("");
|
|
}
|
|
};
|
|
const down = () => setPressed(true);
|
|
const up = () => setPressed(false);
|
|
|
|
window.addEventListener("pointermove", move, { passive: true });
|
|
window.addEventListener("pointerdown", down, { passive: true });
|
|
window.addEventListener("pointerup", up, { passive: true });
|
|
return () => {
|
|
window.removeEventListener("pointermove", move);
|
|
window.removeEventListener("pointerdown", down);
|
|
window.removeEventListener("pointerup", up);
|
|
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" : ""
|
|
} ${pressed ? "is-pressed" : ""}`}
|
|
style={{ x: ringX, y: ringY }}
|
|
>
|
|
{label && <span className="cursor-ring__label">{label}</span>}
|
|
</motion.div>
|
|
<motion.div
|
|
className={`cursor-dot ${pressed ? "is-pressed" : ""}`}
|
|
style={{ x: dotX, y: dotY }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|