Add a custom site cursor component
This commit is contained in:
149
components/SiteCursor.tsx
Normal file
149
components/SiteCursor.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user