150 lines
5.0 KiB
TypeScript
150 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { usePathname } from "next/navigation";
|
|
import { ArrowUpRight } from "lucide-react";
|
|
|
|
const nativeTargets =
|
|
'input, textarea, select, [contenteditable="true"], canvas, video, iframe, dialog[open], [popover], .leaflet-container, [data-native-cursor], [role="slider"], button:disabled, [aria-disabled="true"]';
|
|
const interactiveTargets =
|
|
'a[href], button, summary, label[for], [role="button"]';
|
|
|
|
export function SiteCursor() {
|
|
const cursorRef = useRef<HTMLDivElement>(null);
|
|
const ringRef = useRef<HTMLDivElement>(null);
|
|
const dotRef = useRef<HTMLDivElement>(null);
|
|
const pathname = usePathname();
|
|
|
|
useEffect(() => {
|
|
const cursor = cursorRef.current;
|
|
const ring = ringRef.current;
|
|
const dot = dotRef.current;
|
|
if (!cursor || !ring || !dot) return;
|
|
|
|
const preference = matchMedia(
|
|
"(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)",
|
|
);
|
|
let visible = false;
|
|
let frame = 0;
|
|
let previousTime = 0;
|
|
let targetX = 0;
|
|
let targetY = 0;
|
|
let ringX = 0;
|
|
let ringY = 0;
|
|
|
|
const hide = () => {
|
|
visible = false;
|
|
cursor.dataset.visible = "false";
|
|
cursor.dataset.pressed = "false";
|
|
document.documentElement.classList.remove("has-site-cursor");
|
|
cancelAnimationFrame(frame);
|
|
frame = 0;
|
|
};
|
|
|
|
const paint = (time: number) => {
|
|
const elapsed = Math.min(time - previousTime || 16, 40);
|
|
previousTime = time;
|
|
const follow = 1 - Math.exp(-elapsed / 55);
|
|
ringX += (targetX - ringX) * follow;
|
|
ringY += (targetY - ringY) * follow;
|
|
ring.style.transform = `translate3d(${ringX}px, ${ringY}px, 0)`;
|
|
dot.style.transform = `translate3d(${targetX}px, ${targetY}px, 0)`;
|
|
if (Math.hypot(targetX - ringX, targetY - ringY) > 0.1) {
|
|
frame = requestAnimationFrame(paint);
|
|
} else {
|
|
frame = 0;
|
|
}
|
|
};
|
|
|
|
const move = (event: PointerEvent) => {
|
|
const element = event.target instanceof Element ? event.target : null;
|
|
if (
|
|
!preference.matches ||
|
|
event.pointerType !== "mouse" ||
|
|
document.pointerLockElement ||
|
|
!element ||
|
|
element.closest(nativeTargets)
|
|
) {
|
|
hide();
|
|
return;
|
|
}
|
|
|
|
targetX = event.clientX;
|
|
targetY = event.clientY;
|
|
cursor.dataset.mode = element.closest(interactiveTargets)
|
|
? "link"
|
|
: "default";
|
|
if (!visible) {
|
|
ringX = targetX;
|
|
ringY = targetY;
|
|
previousTime = 0;
|
|
ring.style.transform = `translate3d(${ringX}px, ${ringY}px, 0)`;
|
|
dot.style.transform = `translate3d(${targetX}px, ${targetY}px, 0)`;
|
|
visible = true;
|
|
cursor.dataset.visible = "true";
|
|
document.documentElement.classList.add("has-site-cursor");
|
|
}
|
|
if (!frame) {
|
|
previousTime = 0;
|
|
frame = requestAnimationFrame(paint);
|
|
}
|
|
};
|
|
|
|
const leave = (event: PointerEvent) => {
|
|
if (!event.relatedTarget) hide();
|
|
};
|
|
const press = () => {
|
|
cursor.dataset.pressed = "true";
|
|
};
|
|
const release = () => {
|
|
cursor.dataset.pressed = "false";
|
|
};
|
|
const keyboard = (event: KeyboardEvent) => {
|
|
if (event.key === "Tab" || event.key === "Escape") hide();
|
|
};
|
|
const visibility = () => {
|
|
if (document.hidden) hide();
|
|
};
|
|
|
|
document.addEventListener("pointermove", move, { passive: true });
|
|
document.addEventListener("pointerover", move, { passive: true });
|
|
document.addEventListener("pointerout", leave, { passive: true });
|
|
document.addEventListener("pointerdown", press, { passive: true });
|
|
document.addEventListener("pointerup", release, { passive: true });
|
|
document.addEventListener("pointercancel", hide);
|
|
document.addEventListener("keydown", keyboard);
|
|
document.addEventListener("visibilitychange", visibility);
|
|
document.addEventListener("pointerlockchange", hide);
|
|
window.addEventListener("blur", hide);
|
|
preference.addEventListener("change", hide);
|
|
|
|
return () => {
|
|
hide();
|
|
document.removeEventListener("pointermove", move);
|
|
document.removeEventListener("pointerover", move);
|
|
document.removeEventListener("pointerout", leave);
|
|
document.removeEventListener("pointerdown", press);
|
|
document.removeEventListener("pointerup", release);
|
|
document.removeEventListener("pointercancel", hide);
|
|
document.removeEventListener("keydown", keyboard);
|
|
document.removeEventListener("visibilitychange", visibility);
|
|
document.removeEventListener("pointerlockchange", hide);
|
|
window.removeEventListener("blur", hide);
|
|
preference.removeEventListener("change", hide);
|
|
};
|
|
}, [pathname]);
|
|
|
|
return (
|
|
<div className="site-cursor" ref={cursorRef} aria-hidden="true">
|
|
<div className="site-cursor-ring" ref={ringRef}>
|
|
<div className="site-cursor-orbit">
|
|
<ArrowUpRight size={20} strokeWidth={1.5} />
|
|
</div>
|
|
</div>
|
|
<div className="site-cursor-dot" ref={dotRef}>
|
|
<span />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|