Gesture·09.7
Cursor
The pointer replaced, inside one frame only.
Move inside the frame
The pointer is replaced here and nowhere else.
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/cursor.jsonUsage
"use client";
import { useRef } from "react";
import { Cursor } from "@/components/yugo/cursor";
const TRAIL = { stiffness: 520, damping: 34, mass: 0.45 } as const;
export function Canvas() {
const area = useRef<HTMLDivElement>(null);
return (
<div ref={area} className="relative h-80 w-full overflow-hidden">
<p>Move inside the frame</p>
<Cursor attachTo={area} spring={TRAIL}>
<span className="block size-[10px] rounded-[2px] bg-[#4568FF]" />
</Cursor>
</div>
);
}Source
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useSpring,
type MotionValue,
type SpringOptions,
} from "motion/react";
const EASE = [0.23, 1, 0.32, 1] as const;
const LEAVE = [0.4, 0, 1, 1] as const;
const INSTANT = { duration: 0 } as const;
const FINE = "(pointer: fine)";
export type UseCursorOptions = {
/** the element the cursor belongs to. Omitted, it follows the whole page. */
attachTo?: React.RefObject<HTMLElement | null>;
/** a spring to lag behind the pointer with, or null to sit exactly on it */
spring?: SpringOptions | null;
hideNative?: boolean;
onMove?: (x: number, y: number) => void;
};
export type UseCursorResult = {
x: MotionValue<number>;
y: MotionValue<number>;
visible: boolean;
/** false on touch, where replacing the pointer would replace nothing */
fine: boolean;
};
/**
* The pointer, as two MotionValues. Nothing here re-renders on a move: the
* position is written straight into the values and React only hears about
* the mark arriving and leaving. §5h
*/
export function useCursor({
attachTo,
spring = null,
hideNative = true,
onMove,
}: UseCursorOptions = {}): UseCursorResult {
const reduced = useReducedMotion();
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, spring ?? INSTANT);
const sy = useSpring(y, spring ?? INSTANT);
const [fine, setFine] = useState(false);
const [visible, setVisible] = useState(false);
const landed = useRef(false);
const report = useRef(onMove);
report.current = onMove;
// A trailing cursor under reduced motion is the one thing the preference
// is asking for less of, so the lag is dropped and the mark sits on the
// pointer. The information — where you are — still arrives.
const trailing = Boolean(spring) && !reduced;
useEffect(() => {
const query = window.matchMedia(FINE);
const read = () => setFine(query.matches);
read();
query.addEventListener("change", read);
return () => query.removeEventListener("change", read);
}, []);
useEffect(() => {
if (!fine) return;
const host = attachTo?.current ?? null;
// A ref that has not resolved must never fall through to the page: that
// is how a cursor scoped to one card ends up owning the whole document.
if (attachTo && !host) return;
const move = (event: MouseEvent) => {
if (host && !host.contains(event.target as Node | null)) {
setVisible(false);
return;
}
x.set(event.clientX);
y.set(event.clientY);
// The first sample is a jump, not a trip: a mark that springs in from
// the top-left corner is a mark that lied about where the pointer was.
if (!landed.current) {
landed.current = true;
sx.jump(event.clientX);
sy.jump(event.clientY);
}
setVisible(true);
report.current?.(event.clientX, event.clientY);
};
const hide = () => setVisible(false);
const backgrounded = () => {
if (document.hidden) hide();
};
document.addEventListener("mousemove", move);
document.addEventListener("pointercancel", hide);
document.documentElement.addEventListener("mouseleave", hide);
window.addEventListener("blur", hide);
document.addEventListener("visibilitychange", backgrounded);
return () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("pointercancel", hide);
document.documentElement.removeEventListener("mouseleave", hide);
window.removeEventListener("blur", hide);
document.removeEventListener("visibilitychange", backgrounded);
};
}, [fine, attachTo, x, y, sx, sy]);
useEffect(() => {
if (!fine || !hideNative || !visible) return;
const host = attachTo?.current ?? document.body;
const previous = host.style.cursor;
host.style.cursor = "none";
return () => {
host.style.cursor = previous;
};
}, [fine, hideNative, visible, attachTo]);
return {
x: trailing ? sx : x,
y: trailing ? sy : y,
visible: visible && fine,
fine,
};
}
export type CursorProps = UseCursorOptions & {
children?: React.ReactNode;
className?: string;
};
export function Cursor({ children, className = "", ...options }: CursorProps) {
const reduced = useReducedMotion();
const { x, y, visible } = useCursor(options);
// Portalled, like every fixed surface in the set: position fixed is enough
// right up until an ancestor grows a transform or a filter, at which point
// the layer silently becomes a child of that box instead of the page — and
// that ancestor is rarely yours. §16
const [host, setHost] = useState<HTMLElement | null>(null);
useEffect(() => setHost(document.body), []);
if (!host) return null;
return createPortal(
<motion.div
aria-hidden="true"
className={`pointer-events-none fixed left-0 top-0 z-50 ${className}`}
style={{ x, y, translateX: "-50%", translateY: "-50%" }}
>
<AnimatePresence>
{visible ? (
<motion.div
key="mark"
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={
reduced
? { opacity: 0, transition: { duration: 0.1 } }
: {
opacity: 0,
scale: 0.94,
transition: { duration: 0.12, ease: LEAVE },
}
}
transition={reduced ? INSTANT : { duration: 0.18, ease: EASE }}
>
{children ?? (
<span className="block size-[10px] rounded-[2px] bg-[#4568FF] shadow-[0_1px_3px_rgba(28,25,23,0.35)] dark:bg-[#93B0FF]" />
)}
</motion.div>
) : null}
</AnimatePresence>
</motion.div>,
host,
);
}Props
childrenReactNodeThe mark drawn at the pointer. Omitted, it is a 10px accent square: a circle would be the one shape this set does not spend without a reason.
attachToRefObject<HTMLElement | null>The element the cursor belongs to. Omitted, it follows the whole page, which is a decision worth making deliberately.
springnullSpringOptions | nullA spring to lag behind the pointer with. Null sits the mark exactly on it, which is the honest default: a cursor that lags is a cursor reporting a position you are not at.
hideNativetruebooleanWhether to hide the system pointer while the mark is up. The previous inline value is recorded and put back, not overwritten with auto.
onMove(x: number, y: number) => voidEvery sample, in client coordinates. It fires from the listener, not from a render.
className""stringAppended last to the fixed layer.