Add a custom site cursor component
This commit is contained in:
81
app/cursor.css
Normal file
81
app/cursor.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import "./workshop.css";
|
|||||||
import "./leaderboard.css";
|
import "./leaderboard.css";
|
||||||
import "./home-map.css";
|
import "./home-map.css";
|
||||||
import "./rounded-theme.css";
|
import "./rounded-theme.css";
|
||||||
|
import "./cursor.css";
|
||||||
|
import { SiteCursor } from "@/components/SiteCursor";
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: "MineGeo — Get lost in a familiar world",
|
default: "MineGeo — Get lost in a familiar world",
|
||||||
@@ -22,7 +24,10 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
{children}
|
||||||
|
<SiteCursor />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,36 +202,6 @@ body {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
overflow: hidden;
|
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) {
|
@media (max-width: 1000px) {
|
||||||
.hero {
|
.hero {
|
||||||
grid-template-columns: 1.15fr 1fr;
|
grid-template-columns: 1.15fr 1fr;
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">
|
|
||||||
<circle cx="18" cy="18" r="14" fill="#8dce66" fill-opacity=".18" stroke="#080b10" stroke-width="4"/>
|
|
||||||
<circle cx="18" cy="18" r="14" fill="none" stroke="#b1e78b" stroke-width="2"/>
|
|
||||||
<circle cx="18" cy="18" r="3" fill="#b1e78b" stroke="#080b10" stroke-width="1"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 358 B |
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
|
|
||||||
<circle cx="14" cy="14" r="10" fill="#080b10" fill-opacity=".22" stroke="#080b10" stroke-width="4"/>
|
|
||||||
<circle cx="14" cy="14" r="10" fill="none" stroke="#b1e78b" stroke-width="1.5"/>
|
|
||||||
<circle cx="14" cy="14" r="2" fill="#eef1f4" stroke="#080b10" stroke-width="1"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 360 B |
Reference in New Issue
Block a user