agency-web/app/components/Cursor.tsx

64 lines
1.9 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
/**
* A small "terminal crosshair" cursor that grows into a reading ring over
* interactive targets. Pointer-device only; hidden for touch / reduced
* motion. Purely decorative (aria-hidden) — never the only affordance.
*/
export default function Cursor() {
const ring = useRef<HTMLDivElement>(null);
const label = useRef<HTMLSpanElement>(null);
useEffect(() => {
const fine = window.matchMedia("(pointer: fine)").matches;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!fine || reduce) return;
const el = ring.current;
const lab = label.current;
if (!el || !lab) return;
let x = window.innerWidth / 2;
let y = window.innerHeight / 2;
let cx = x;
let cy = y;
let raf = 0;
const move = (e: PointerEvent) => {
x = e.clientX;
y = e.clientY;
const t = e.target as HTMLElement;
const tgt = t?.closest("a, button, [data-cursor]");
el.dataset.active = tgt ? "1" : "0";
const txt = (tgt as HTMLElement)?.dataset?.cursor;
lab.textContent = txt || "";
el.dataset.hasLabel = txt ? "1" : "0";
};
const loop = () => {
cx += (x - cx) * 0.2;
cy += (y - cy) * 0.2;
el.style.transform = `translate3d(${cx}px, ${cy}px, 0) translate(-50%, -50%)`;
raf = requestAnimationFrame(loop);
};
document.body.classList.add("has-cursor");
window.addEventListener("pointermove", move);
raf = requestAnimationFrame(loop);
return () => {
window.removeEventListener("pointermove", move);
cancelAnimationFrame(raf);
document.body.classList.remove("has-cursor");
};
}, []);
return (
<div ref={ring} className="cursor" aria-hidden="true" data-active="0">
<span className="cursor__cross" />
<span ref={label} className="cursor__label" />
</div>
);
}