Spaces:
Running
Running
File size: 3,617 Bytes
fb81cea 07a9968 fb81cea 07a9968 7de7656 07a9968 fb81cea 07a9968 fb81cea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 | /**
* CustomCursor — a small companion dot that follows the mouse and
* subtly scales when over interactive elements.
*
* Intentionally *additive*: the native OS cursor is preserved (text
* selection still feels right), and this dot just rides alongside. It's
* a craft detail that communicates "this page notices you" — the same
* signal as a cursor-glow spotlight but at a smaller, precise scale.
*
* Motion is driven via CSS custom properties (--x, --y) set on a fixed
* element rather than React state — so movement never triggers rerenders.
*
* Disabled on coarse pointers (touch) and when reduced motion is preferred.
*/
import { useEffect, useRef, useState } from "react";
const INTERACTIVE_SELECTOR = [
"button",
"a",
'[role="button"]',
'[role="tab"]',
".ts-flow-step",
".ts-rail-dot",
".ts-dep-pill",
".suggestion-btn",
".try-repo-chip",
".ec-ask",
".ec-node",
".onboarding-step",
].join(", ");
// Surfaces where the dot becomes noise instead of signal:
// - inputs / textareas: the I-beam is the actual pointer, the dot fights it
// - pre / code: monospaced text where a 10px dot reads like a glyph
// - contenteditable: same as inputs
// We hide the dot entirely over these. Pure-prose elements (p, span) are
// NOT in this list — the dot is useful ambient signal over long reading.
const HIDE_OVER_SELECTOR = [
'input:not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"])',
'textarea',
'[contenteditable="true"]',
'pre',
'code',
'.mcp-detail-preview',
].join(', ');
export default function CustomCursor() {
const ref = useRef(null);
// Only enabled if hover is supported + user hasn't requested reduced motion.
// Decided once on mount — both media queries are user-level, not volatile.
const [enabled] = useState(() => {
if (typeof window === "undefined") return false;
const hasHover = window.matchMedia?.("(hover: hover)").matches;
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
return hasHover && !reduced;
});
useEffect(() => {
if (!enabled) return;
const el = ref.current;
if (!el) return;
function onMove(e) {
el.style.setProperty("--x", `${e.clientX}px`);
el.style.setProperty("--y", `${e.clientY}px`);
if (el.dataset.visible !== "1") {
el.dataset.visible = "1";
}
}
function onOver(e) {
// Hide entirely over text-entry surfaces; otherwise upgrade to the
// active ring when over any interactive element in the bubble path.
const overInput = !!e.target.closest?.(HIDE_OVER_SELECTOR);
el.dataset.overInput = overInput ? "1" : "0";
const interactive = !overInput && e.target.closest?.(INTERACTIVE_SELECTOR);
el.dataset.active = interactive ? "1" : "0";
}
function onLeave() { el.dataset.visible = "0"; }
function onEnter() { el.dataset.visible = "1"; }
window.addEventListener("mousemove", onMove, { passive: true });
window.addEventListener("mouseover", onOver, { passive: true });
document.addEventListener("mouseleave", onLeave);
document.addEventListener("mouseenter", onEnter);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseover", onOver);
document.removeEventListener("mouseleave", onLeave);
document.removeEventListener("mouseenter", onEnter);
};
}, [enabled]);
if (!enabled) return null;
return <div ref={ref} className="custom-cursor" aria-hidden="true" data-visible="0" data-active="0" data-over-input="0" />;
}
|