diff --git a/app/cursor.css b/app/cursor.css new file mode 100644 index 0000000..a6639cc --- /dev/null +++ b/app/cursor.css @@ -0,0 +1,81 @@ +/* Immediate aiming dot, soft ring follow, and an arrow on actionable targets. */ +.site-cursor { + position: fixed; + inset: 0; + z-index: 100; + pointer-events: none; + opacity: 0; + transition: opacity 120ms; +} +.site-cursor[data-visible="true"] { + opacity: 1; +} +.site-cursor-ring, +.site-cursor-dot { + position: absolute; + top: 0; + left: 0; + will-change: transform; +} +.site-cursor-orbit { + width: 36px; + height: 36px; + margin: -18px; + display: grid; + place-items: center; + border: 1px solid #dbead0a6; + border-radius: 50%; + color: #162310; + transition: + transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1), + background-color 220ms, + border-color 220ms; +} +.site-cursor-orbit svg { + opacity: 0; + transform: translate(-3px, 3px) scale(0.75); + transition: + opacity 160ms, + transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +.site-cursor-dot span { + display: block; + width: 4px; + height: 4px; + margin: -2px; + border-radius: 50%; + background: #f0f6eb; + box-shadow: 0 0 1px #081006; + transition: + opacity 140ms, + transform 160ms; +} +.site-cursor[data-mode="link"] .site-cursor-orbit { + transform: scale(1.45); + border-color: #b4e78a; + background: #b4e78af2; +} +.site-cursor[data-mode="link"] .site-cursor-orbit svg { + opacity: 1; + transform: translate(0, 0) scale(0.8); +} +.site-cursor[data-mode="link"] .site-cursor-dot span { + opacity: 0; +} +.site-cursor[data-pressed="true"] .site-cursor-orbit { + transform: scale(0.85); +} +.site-cursor[data-mode="link"][data-pressed="true"] .site-cursor-orbit { + transform: scale(1.2); +} +@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) { + html.has-site-cursor, + html.has-site-cursor * { + cursor: none !important; + } +} +@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) { + .site-cursor { + display: none; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 1e8b4a2..00b0b5d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,8 @@ import "./workshop.css"; import "./leaderboard.css"; import "./home-map.css"; import "./rounded-theme.css"; +import "./cursor.css"; +import { SiteCursor } from "@/components/SiteCursor"; export const metadata: Metadata = { title: { default: "MineGeo — Get lost in a familiar world", @@ -22,7 +24,10 @@ export default function RootLayout({ }) { return ( - {children} + + {children} + + ); } diff --git a/app/rounded-theme.css b/app/rounded-theme.css index 6997212..0468f5d 100644 --- a/app/rounded-theme.css +++ b/app/rounded-theme.css @@ -202,36 +202,6 @@ body { border-radius: 5px; overflow: hidden; } -/* Native vector cursors track directly, with normal text/drag cursors retained. */ -@media (hover: hover) and (pointer: fine) { - body { - cursor: - url("/cursors/orbit.svg") 14 14, - auto; - } - a, - button, - select, - summary, - label[for], - input[type="checkbox"], - input[type="radio"], - input[type="file"], - [role="button"] { - cursor: - url("/cursors/orbit-link.svg") 18 18, - pointer; - } - input:not([type="checkbox"]):not([type="radio"]):not([type="file"]), - textarea, - [contenteditable="true"] { - cursor: text; - } - button:disabled, - [aria-disabled="true"] { - cursor: not-allowed; - } -} @media (max-width: 1000px) { .hero { grid-template-columns: 1.15fr 1fr; diff --git a/components/SiteCursor.tsx b/components/SiteCursor.tsx new file mode 100644 index 0000000..1898a56 --- /dev/null +++ b/components/SiteCursor.tsx @@ -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(null); + const ringRef = useRef(null); + const dotRef = useRef(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 ( + + ); +} diff --git a/public/cursors/orbit-link.svg b/public/cursors/orbit-link.svg deleted file mode 100644 index 21964f3..0000000 --- a/public/cursors/orbit-link.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/public/cursors/orbit.svg b/public/cursors/orbit.svg deleted file mode 100644 index 0f715f3..0000000 --- a/public/cursors/orbit.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - -