64 lines
1.9 KiB
TypeScript
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>
|
|
);
|
|
}
|