79 lines
2.7 KiB
TypeScript
79 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Cursor — a character cursor with a difference-blend "lens".
|
|
*
|
|
* A small dot tracks tightly; a larger ring trails with easing and inverts the
|
|
* content beneath it (mix-blend-mode: difference). Over interactive elements it
|
|
* grows; over elements carrying data-cursor it swaps in a contextual label
|
|
* (e.g. "ver"). Disabled on touch / coarse pointers. Native cursor remains as a
|
|
* fallback so the page is always usable.
|
|
*/
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { gsap } from "gsap";
|
|
|
|
export default function Cursor() {
|
|
const dot = useRef<HTMLDivElement>(null);
|
|
const ring = useRef<HTMLDivElement>(null);
|
|
const label = useRef<HTMLSpanElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (window.matchMedia("(pointer: coarse)").matches) return;
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
if (!dot.current || !ring.current) return;
|
|
|
|
document.body.classList.add("has-custom-cursor");
|
|
|
|
const xD = gsap.quickTo(dot.current, "x", { duration: 0.12, ease: "power3" });
|
|
const yD = gsap.quickTo(dot.current, "y", { duration: 0.12, ease: "power3" });
|
|
const xR = gsap.quickTo(ring.current, "x", { duration: 0.55, ease: "power3" });
|
|
const yR = gsap.quickTo(ring.current, "y", { duration: 0.55, ease: "power3" });
|
|
|
|
const move = (e: MouseEvent) => {
|
|
xD(e.clientX);
|
|
yD(e.clientY);
|
|
xR(e.clientX);
|
|
yR(e.clientY);
|
|
};
|
|
|
|
const over = (e: Event) => {
|
|
const t = (e.target as HTMLElement).closest<HTMLElement>(
|
|
"a,button,.hoverable,[data-cursor]"
|
|
);
|
|
if (!t) return;
|
|
ring.current?.classList.add("cursor-ring--big");
|
|
const text = t.getAttribute("data-cursor");
|
|
if (text && label.current) {
|
|
label.current.textContent = text;
|
|
ring.current?.classList.add("cursor-ring--label");
|
|
}
|
|
};
|
|
const out = (e: Event) => {
|
|
const t = (e.target as HTMLElement).closest<HTMLElement>(
|
|
"a,button,.hoverable,[data-cursor]"
|
|
);
|
|
if (!t) return;
|
|
ring.current?.classList.remove("cursor-ring--big", "cursor-ring--label");
|
|
};
|
|
|
|
window.addEventListener("mousemove", move, { passive: true });
|
|
document.addEventListener("mouseover", over);
|
|
document.addEventListener("mouseout", out);
|
|
return () => {
|
|
document.body.classList.remove("has-custom-cursor");
|
|
window.removeEventListener("mousemove", move);
|
|
document.removeEventListener("mouseover", over);
|
|
document.removeEventListener("mouseout", out);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<div ref={ring} className="cursor-ring" aria-hidden="true">
|
|
<span ref={label} className="cursor-ring__label" />
|
|
</div>
|
|
<div ref={dot} className="cursor-dot" aria-hidden="true" />
|
|
</>
|
|
);
|
|
}
|