Text Cursor Label
Cursors & Pointer

Text Cursor Label

A dot cursor carrying a contextual action label that blur-swaps its word per [data-cursor] zone (View / Drag / Open), expanding the dot over zones.

Install

npx shadcn@latest add @paragon/text-cursor-label

text-cursor-label.tsx

"use client";

import * as React from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";

/**
 * TextCursorLabel — a small dot cursor that carries a contextual action label.
 * The label text changes per zone: mark any element inside the surface with
 * `data-cursor="View"` (or "Drag", "Open", …) and the pill swaps its word with
 * a blur-and-lift transition as the pointer crosses zone boundaries. Outside a
 * zone it shows the default label (or hides it). The dot grows springily over
 * zones, contracts on press, and the whole cursor teleports to the entry point
 * so nothing streaks in from offscreen.
 *
 * Wrap any content: `<TextCursorLabel>…surface…</TextCursorLabel>`. Native
 * cursor hidden only within the surface; layer is pointer-events-none. Gated on
 * fine-pointer devices. Reduced motion keeps the label, drops all lag + blur.
 */
export interface TextCursorLabelProps extends React.ComponentProps<"div"> {
  /** Label shown when not over a `[data-cursor]` zone. Empty string hides it. */
  defaultLabel?: string;
  /** Dot + pill color. Defaults to the primary token. */
  color?: string;
  /** Pill text color. */
  textColor?: string;
}

const DOT_SPRING: SpringOptions = { stiffness: 600, damping: 32, mass: 0.5 };
const LABEL_SPRING: SpringOptions = { stiffness: 300, damping: 28, mass: 0.7 };
const SCALE_SPRING: SpringOptions = { stiffness: 380, damping: 26, mass: 0.6 };

export function TextCursorLabel({
  defaultLabel = "Explore",
  color = "var(--color-primary)",
  textColor = "var(--color-primary-foreground)",
  className,
  children,
  ...props
}: TextCursorLabelProps) {
  const hostRef = React.useRef<HTMLDivElement>(null);
  const [fine, setFine] = React.useState(false);
  const [inside, setInside] = React.useState(false);
  const insideRef = React.useRef(false);
  const [label, setLabel] = React.useState(defaultLabel);
  const [overZone, setOverZone] = React.useState(false);
  const reduced = useReducedMotion();

  const x = useMotionValue(-9999);
  const y = useMotionValue(-9999);
  const dotX = useSpring(x, DOT_SPRING);
  const dotY = useSpring(y, DOT_SPRING);
  const labelX = useSpring(x, LABEL_SPRING);
  const labelY = useSpring(y, LABEL_SPRING);

  // Dot scale = zone growth × press contraction, each on its own spring.
  const zoneScale = useMotionValue(1);
  const press = useMotionValue(1);
  const dotScale = useTransform([zoneScale, press], ([z, p]: number[]) => z * p);

  React.useEffect(() => {
    const controls = animate(zoneScale, overZone ? 1.9 : 1, {
      type: "spring",
      ...SCALE_SPRING,
    });
    return () => controls.stop();
  }, [overZone, zoneScale]);

  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;

    const enterAt = (px: number, py: number) => {
      x.jump(px);
      y.jump(py);
      dotX.jump(px);
      dotY.jump(py);
      labelX.jump(px);
      labelY.jump(py);
      insideRef.current = true;
      setInside(true);
    };

    const onMove = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      const px = e.clientX - rect.left;
      const py = e.clientY - rect.top;
      if (!insideRef.current) {
        enterAt(px, py);
      } else if (reduced) {
        x.jump(px);
        y.jump(py);
        dotX.jump(px);
        dotY.jump(py);
        labelX.jump(px);
        labelY.jump(py);
      } else {
        x.set(px);
        y.set(py);
      }
      const zone = (e.target as Element | null)?.closest?.(
        "[data-cursor]",
      ) as HTMLElement | null;
      if (zone && el.contains(zone)) {
        setOverZone(true);
        setLabel(zone.dataset.cursor || defaultLabel);
      } else {
        setOverZone(false);
        setLabel(defaultLabel);
      }
    };
    const onEnter = (e: PointerEvent) => onMove(e);
    const onLeave = () => {
      insideRef.current = false;
      setInside(false);
      setOverZone(false);
      animate(press, 1, { type: "spring", ...SCALE_SPRING });
    };
    const onDown = () =>
      animate(press, 0.85, { type: "spring", stiffness: 500, damping: 30, mass: 0.5 });
    const onUp = () =>
      animate(press, 1, { type: "spring", stiffness: 500, damping: 30, 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, reduced, defaultLabel, x, y, dotX, dotY, labelX, labelY, press]);

  const showLabel = label.length > 0;

  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 150ms ease" }}
        >
          {showLabel && (
            <motion.div
              className="absolute top-0 left-0 rounded-full px-2.5 py-1 text-[11px] font-semibold whitespace-nowrap shadow-overlay"
              style={{
                x: labelX,
                y: labelY,
                translateX: 14,
                translateY: 10,
                background: color,
                color: textColor,
              }}
            >
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.span
                  key={label}
                  className="inline-block"
                  initial={
                    reduced
                      ? { opacity: 0 }
                      : { opacity: 0, y: 6, filter: "blur(4px)" }
                  }
                  animate={
                    reduced
                      ? { opacity: 1 }
                      : { opacity: 1, y: 0, filter: "blur(0px)" }
                  }
                  exit={
                    reduced
                      ? { opacity: 0 }
                      : { opacity: 0, y: -6, filter: "blur(4px)" }
                  }
                  transition={{ type: "spring", duration: 0.3, bounce: 0 }}
                >
                  {label}
                </motion.span>
              </AnimatePresence>
            </motion.div>
          )}
          <motion.span
            className="absolute top-0 left-0 rounded-full"
            style={{
              x: dotX,
              y: dotY,
              scale: dotScale,
              width: 8,
              height: 8,
              marginLeft: -4,
              marginTop: -4,
              background: color,
            }}
          />
        </div>
      )}
    </div>
  );
}