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.
npx shadcn@latest add @paragon/text-cursor-label"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>
);
}