cartographer / ui /src /components /CustomCursor.jsx
umanggarg's picture
Fix prod crash: TEXT_INPUT_SELECTOR reference in CustomCursor
7de7656
/**
* 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" />;
}