Cursors & Pointer

User Cursor

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.

Install

npx shadcn@latest add @paragon/user-cursor

user-cursor.tsx

"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>
  );
}