A custom cursor that replaces the native pointer inside its surface — spring-tracked arrow with a trailing name pill that rocks with velocity and scales on press. Ideal for live-collaboration presence.
npx shadcn@latest add @paragon/user-cursor"use client";
import * as React from "react";
import {
animate,
motion,
useMotionValue,
useSpring,
useTransform,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* UserCursor — a custom cursor that replaces the native pointer inside its own
* surface: an arrow glyph tracks the pointer on a snappy spring while a colored
* label pill trails behind on a laggier spring, rocking with velocity and
* scaling on press. Perfect for live-collaboration presence and product demos.
*
* Wrap any content: `<UserCursor name="You">…surface…</UserCursor>`. The native
* cursor is hidden only inside the surface; the cursor layer is pointer-events
* none so clicks pass through. Skipped on coarse-pointer (touch) devices.
*/
export interface UserCursorProps extends React.ComponentProps<"div"> {
/** Name shown in the trailing label pill. */
name?: string;
/** Pill + arrow color. Defaults to the primary token. */
color?: string;
/** Label text color. */
textColor?: string;
/** Arrow size in px. */
size?: number;
/** Show the trailing name pill. */
showLabel?: boolean;
/** Max label rock, in degrees, driven by velocity. */
labelTilt?: number;
/** Scale applied while the pointer is pressed. */
pressScale?: number;
}
const ARROW_SPRING: SpringOptions = { stiffness: 380, damping: 32, mass: 0.6 };
const LABEL_SPRING: SpringOptions = { stiffness: 220, damping: 26, mass: 0.7 };
export function UserCursor({
name = "You",
color = "var(--color-primary)",
textColor = "var(--color-primary-foreground)",
size = 22,
showLabel = true,
labelTilt = 16,
pressScale = 0.9,
className,
children,
...props
}: UserCursorProps) {
const hostRef = React.useRef<HTMLDivElement>(null);
const [fine, setFine] = React.useState(false);
const [inside, setInside] = React.useState(false);
const x = useMotionValue(-9999);
const y = useMotionValue(-9999);
const arrowX = useSpring(x, ARROW_SPRING);
const arrowY = useSpring(y, ARROW_SPRING);
const labelX = useSpring(x, LABEL_SPRING);
const labelY = useSpring(y, LABEL_SPRING);
const scale = useMotionValue(1);
const tiltTarget = useMotionValue(0);
const tilt = useSpring(tiltTarget, { stiffness: 200, damping: 24, mass: 0.6 });
const labelTx = useTransform(labelX, (v) => v + size * 0.85);
const labelTy = useTransform(labelY, (v) => v + size * 0.2 + 6);
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setFine(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
React.useEffect(() => {
const el = hostRef.current;
if (!el || !fine) return;
let last: { x: number; y: number; t: number } | null = null;
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
const now = performance.now();
if (last) {
const dt = Math.max(1, now - last.t);
const vx = ((px - last.x) / dt) * 1000;
const vy = ((py - last.y) / dt) * 1000;
const speed = Math.hypot(vx, vy);
const sign = vx === 0 ? 0 : vx > 0 ? 1 : -1;
tiltTarget.set(sign * Math.min(1, speed / 1500) * labelTilt);
}
last = { x: px, y: py, t: now };
x.set(px);
y.set(py);
};
const onEnter = () => setInside(true);
const onLeave = () => {
setInside(false);
last = null;
tiltTarget.set(0);
};
const onDown = () => animate(scale, pressScale, { type: "spring", stiffness: 500, damping: 28, mass: 0.5 });
const onUp = () => animate(scale, 1, { type: "spring", stiffness: 500, damping: 28, mass: 0.5 });
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerenter", onEnter);
el.addEventListener("pointerleave", onLeave);
el.addEventListener("pointerdown", onDown);
el.addEventListener("pointerup", onUp);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerenter", onEnter);
el.removeEventListener("pointerleave", onLeave);
el.removeEventListener("pointerdown", onDown);
el.removeEventListener("pointerup", onUp);
};
}, [fine, labelTilt, pressScale, x, y, scale, tiltTarget]);
return (
<div
ref={hostRef}
className={cn("relative overflow-hidden", fine && "[&_*]:cursor-none", className)}
style={fine ? { cursor: "none" } : undefined}
{...props}
>
{children}
{fine && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-50"
style={{ opacity: inside ? 1 : 0, transition: "opacity 140ms ease" }}
>
{showLabel && (
<motion.div
className="absolute top-0 left-0 rounded-full px-2 py-1 text-xs font-semibold whitespace-nowrap shadow-overlay"
style={{
x: labelTx,
y: labelTy,
rotate: tilt,
scale,
background: color,
color: textColor,
transformOrigin: "0% 50%",
}}
>
{name}
</motion.div>
)}
<motion.svg
width={size}
height={size}
viewBox="0 0 28 28"
fill="none"
className="absolute top-0 left-0"
style={{ x: arrowX, y: arrowY, scale, transformOrigin: "0% 0%" }}
>
<path
d="M5 3 L23 14 L14 16 L11 24 Z"
fill={color}
stroke="rgba(0,0,0,0.18)"
strokeWidth={0.6}
strokeLinejoin="round"
/>
</motion.svg>
</div>
)}
</div>
);
}