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 @@
-