Cursor Label Follower
A pill that springs after the pointer and morphs its text, glyph and width per target (View, Drag, Play), with press-to-reveal on touch and focus labels for keyboards.
DOM + CSS0 deps1 file · 16 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
/**
* CursorLabelFollower: a pill that springs after the pointer and names whatever
* it is over. Any descendant with data-cursor-label="View" sets the text; an
* optional data-cursor-icon="view | drag | play | pause | arrow" sets the glyph.
* Text, glyph and width morph between targets. Touch: the label appears on
* press, lifted above the finger. Keyboard: it appears on focus-visible.
* The chip is decorative (aria-hidden): repeat the label in the target's name.
*/
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
const ICONS = ["view", "drag", "play", "pause", "arrow"];
const TOUCH_LIFT = 54;
const TBOX_T = "width 0.34s cubic-bezier(0.16, 1, 0.3, 1)";
const SLOT_T = "width 0.3s cubic-bezier(0.16, 1, 0.3, 1)";
const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
const ICON_RULES = ICONS.concat("dot")
.map((n) => '.clf-slot[data-icon="' + n + '"] .clf-i[data-i="' + n + '"]')
.join(",");
const CSS = [
".clf-hide [data-cursor-label]{cursor:none}",
".clf-i{position:absolute;left:0;top:0;opacity:0;transform:scale(0.3) rotate(-70deg);filter:blur(2px);transition:opacity .2s ease,transform .42s cubic-bezier(.34,1.56,.64,1),filter .2s ease}",
ICON_RULES + "{opacity:1;transform:none;filter:none}",
"@media (prefers-reduced-motion:reduce){.clf-i,.clf-chip,.clf-slot,.clf-tbox{transition-duration:.01ms!important}}",
].join("\n");
function inkFor(hex: string): string {
let h = hex.slice(1);
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
const n = parseInt(h, 16);
const lum = (0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255;
return lum > 0.62 ? "#0a0a0a" : "#ffffff";
}
function Glyphs() {
const p = {
className: "clf-i",
viewBox: "0 0 16 16",
width: 14,
height: 14,
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round",
strokeLinejoin: "round",
focusable: "false",
} as const;
return (
<>
<svg {...p} data-i="view">
<path d="M1.5 8s2.3-4.5 6.5-4.5S14.5 8 14.5 8s-2.3 4.5-6.5 4.5S1.5 8 1.5 8Z" />
<circle cx="8" cy="8" r="2.1" />
</svg>
<svg {...p} data-i="drag">
<path d="M5 4.5 1.8 8 5 11.5M11 4.5 14.2 8 11 11.5M2.5 8h11" />
</svg>
<svg {...p} data-i="play">
<path d="M5.6 3.8v8.4L12.2 8 5.6 3.8Z" fill="currentColor" strokeWidth="1.2" />
</svg>
<svg {...p} data-i="pause" strokeWidth={2.3}>
<path d="M5.3 3.8v8.4M10.7 3.8v8.4" />
</svg>
<svg {...p} data-i="arrow">
<path d="M4.5 11.5 11.5 4.5M5.5 4.5h6v6" />
</svg>
<svg {...p} data-i="dot" stroke="none">
<circle cx="4" cy="8" r="2.4" fill="currentColor" opacity="0.75" />
</svg>
</>
);
}
export function CursorLabelFollower({
children,
stiffness = 420,
showDot = true,
color = "#2563eb",
hideCursor = false,
restTarget,
className,
}: {
children: ReactNode;
/** Spring stiffness of the chip trailing the pointer (damping 30, mass 0.35). */
stiffness?: number;
/** The small leading dot inside the chip when the target has no icon. */
showDot?: boolean;
/** Chip colour as #rgb or #rrggbb. Text colour is picked for contrast. */
color?: string;
/** Hide the native cursor over labelled targets (the chip becomes the cursor). */
hideCursor?: boolean;
/** Opt-in: park the chip over the first target with this label when the pointer is away (e.g. "View"). */
restTarget?: string;
className?: string;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const posRef = useRef<HTMLDivElement>(null);
const rotRef = useRef<HTMLDivElement>(null);
const chipRef = useRef<HTMLDivElement>(null);
const slotRef = useRef<HTMLSpanElement>(null);
const tboxRef = useRef<HTMLSpanElement>(null);
const aRef = useRef<HTMLSpanElement>(null);
const bRef = useRef<HTMLSpanElement>(null);
const measRef = useRef<HTMLSpanElement>(null);
const stiffRef = useRef(stiffness);
const dotRef = useRef(showDot);
const restRef = useRef(restTarget);
const parkRef = useRef<() => void>(() => {});
useEffect(() => {
stiffRef.current = stiffness;
dotRef.current = showDot;
}, [stiffness, showDot]);
useEffect(() => {
restRef.current = restTarget;
parkRef.current();
}, [restTarget]);
useEffect(() => {
const root = rootRef.current;
const pos = posRef.current;
const rot = rotRef.current;
const chip = chipRef.current;
const slot = slotRef.current;
const tbox = tboxRef.current;
const la = aRef.current;
const lb = bRef.current;
const meas = measRef.current;
if (!root || !pos || !rot || !chip || !slot || !tbox || !la || !lb || !meas) return;
const layers = [la, lb];
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
let active = 0;
let shown = "";
let curIcon = "";
let slotW = 0;
let textW = 0;
let visible = false;
let pressed = false;
let hover = false;
let x = 0, y = 0, tx = 0, ty = 0, vx = 0, vy = 0;
let raf = 0;
let last = 0;
let hideT = 0;
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
const place = () => {
pos.style.transform = "translate3d(" + x.toFixed(1) + "px," + y.toFixed(1) + "px,0)";
};
const tilt = (deg: number) => {
rot.style.transform = "translate(-50%,-50%) rotate(" + deg.toFixed(2) + "deg)";
};
const paintChip = () => {
chip.style.opacity = visible ? "1" : "0";
chip.style.transform = visible ? (pressed ? "scale(0.9)" : "scale(1)") : "scale(0.6)";
};
const setW = (el: HTMLElement, px: number, t: string, instant: boolean) => {
if (instant) {
el.style.transition = "none";
el.style.width = px + "px";
void el.offsetWidth;
el.style.transition = t;
} else {
el.style.width = px + "px";
}
};
const frame = (t: number) => {
const dt = Math.min(0.05, last ? (t - last) / 1000 : 1 / 60);
last = t;
const k = stiffRef.current;
const h = 1 / 240;
const n = Math.max(1, Math.round(dt / h));
for (let i = 0; i < n; i++) {
vx += ((-k * (x - tx) - 30 * vx) / 0.35) * h;
vy += ((-k * (y - ty) - 30 * vy) / 0.35) * h;
x += vx * h;
y += vy * h;
}
const rest = Math.abs(x - tx) < 0.05 && Math.abs(y - ty) < 0.05 && Math.abs(vx) < 0.5 && Math.abs(vy) < 0.5;
if (rest) {
x = tx;
y = ty;
vx = 0;
vy = 0;
}
place();
tilt(rest ? 0 : clamp(vx / 260, -6, 6));
if (rest) {
raf = 0;
last = 0;
return;
}
raf = requestAnimationFrame(frame);
};
const kick = () => {
if (mq.matches) {
x = tx;
y = ty;
vx = 0;
vy = 0;
place();
return;
}
if (!raf) {
last = 0;
raf = requestAnimationFrame(frame);
}
};
const setTarget = (lx: number, ly: number) => {
const half = (24 + slotW + textW) / 2 + 2;
const w = root.offsetWidth;
tx = w > half * 2 ? clamp(lx, half, w - half) : lx;
ty = Math.max(16, ly);
};
const applyIcon = (name: string, instant: boolean) => {
const nm = ICONS.includes(name) ? name : dotRef.current ? "dot" : "none";
if (nm === curIcon) return;
curIcon = nm;
slot.dataset.icon = nm;
slotW = nm === "none" ? 0 : nm === "dot" ? 13 : 21;
setW(slot, slotW, SLOT_T, instant);
};
const setText = (next: string, instant: boolean) => {
if (next === shown) return;
const inc = layers[1 - active];
const out = layers[active];
if (!inc || !out) return;
meas.textContent = next;
textW = meas.offsetWidth + 1;
setW(tbox, textW, TBOX_T, instant);
inc.getAnimations().forEach((a) => a.cancel());
out.getAnimations().forEach((a) => a.cancel());
inc.textContent = next;
inc.style.opacity = "1";
out.style.opacity = "0";
if (!instant && !mq.matches && inc.animate) {
inc.animate(
[
{ opacity: 0, transform: "translateY(8px)", filter: "blur(3px)" },
{ opacity: 1, transform: "translateY(0)", filter: "blur(0)" },
],
{ duration: 320, easing: "cubic-bezier(0.16, 1, 0.3, 1)" },
);
out.animate(
[
{ opacity: 1, transform: "translateY(0)", filter: "blur(0)" },
{ opacity: 0, transform: "translateY(-8px)", filter: "blur(3px)" },
],
{ duration: 170, easing: "ease-out" },
);
}
active = 1 - active;
shown = next;
};
const hide = () => {
if (!visible) return;
visible = false;
pressed = false;
paintChip();
};
const read = (el: EventTarget | null) => {
const t = el instanceof Element ? el.closest("[data-cursor-label]") : null;
if (!t || !root.contains(t)) return null;
const label = t.getAttribute("data-cursor-label");
if (!label) return null;
return { label, icon: t.getAttribute("data-cursor-icon") ?? "", el: t };
};
let lastEl: Element | null = null;
let lastX = 0;
let lastY = 0;
let release: () => void = hide;
const update = (info: { label: string; icon: string } | null, lx: number, ly: number) => {
lastX = lx;
lastY = ly;
if (!info) {
release();
return;
}
const was = visible;
applyIcon(info.icon, !was);
setText(info.label, !was);
setTarget(lx, ly);
if (!was) {
visible = true;
x = tx;
y = ty;
vx = 0;
vy = 0;
place();
tilt(0);
}
paintChip();
kick();
};
const toLocal = (cx: number, cy: number): [number, number] => {
const r = root.getBoundingClientRect();
const sx = r.width ? root.offsetWidth / r.width : 1;
const sy = r.height ? root.offsetHeight / r.height : 1;
return [(cx - r.left) * sx, (cy - r.top) * sy];
};
const onMove = (e: PointerEvent) => {
window.clearTimeout(hideT);
lastEl = e.target instanceof Element ? e.target : null;
const info = read(e.target);
hover = !!info;
const [lx, ly] = toLocal(e.clientX, e.clientY);
update(info, lx, ly - (e.pointerType === "touch" ? TOUCH_LIFT : 0));
};
const onDown = (e: PointerEvent) => {
onMove(e);
pressed = true;
if (visible) paintChip();
};
const onUp = (e: PointerEvent) => {
pressed = false;
if (visible) paintChip();
if (e.pointerType === "touch") {
hideT = window.setTimeout(() => {
hover = false;
release();
}, 480);
}
};
const onCancel = () => {
pressed = false;
hideT = window.setTimeout(() => {
hover = false;
release();
}, 80);
};
const onLeave = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
hover = false;
release();
};
const onFocusIn = (e: FocusEvent) => {
const info = read(e.target);
if (!info) return;
let fv = true;
try {
fv = (e.target as Element).matches(":focus-visible");
} catch {
fv = true;
}
if (!fv) return;
lastEl = info.el;
const r = info.el.getBoundingClientRect();
const [lx, ly] = toLocal(r.left + r.width / 2, r.top + r.height / 2);
update(info, lx, ly);
};
const onFocusOut = () => {
if (!hover) release();
};
// Opt-in rest state: park over the first target carrying the restTarget label.
release = () => {
const want = restRef.current;
let el: Element | null = null;
if (want) {
el = Array.from(root.querySelectorAll("[data-cursor-label]")).find((n) => n.getAttribute("data-cursor-label") === want) ?? null;
}
const info = el ? read(el) : null;
if (!el || !info) {
hide();
return;
}
lastEl = el;
const r = el.getBoundingClientRect();
const [lx, ly] = toLocal(r.left + r.width * 0.5, r.top + r.height * 0.42);
update(info, lx, ly);
};
parkRef.current = () => {
if (!hover) release();
};
parkRef.current();
// A target may relabel itself (Drag to Dragging, Play to Pause) without the pointer moving.
const mo = new MutationObserver(() => {
if (!visible || !lastEl) return;
const info = read(lastEl);
if (info) update(info, lastX, lastY);
});
mo.observe(root, { subtree: true, attributes: true, attributeFilter: ["data-cursor-label", "data-cursor-icon"] });
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerdown", onDown);
root.addEventListener("pointerup", onUp);
root.addEventListener("pointercancel", onCancel);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("focusin", onFocusIn);
root.addEventListener("focusout", onFocusOut);
return () => {
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerdown", onDown);
root.removeEventListener("pointerup", onUp);
root.removeEventListener("pointercancel", onCancel);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("focusin", onFocusIn);
root.removeEventListener("focusout", onFocusOut);
parkRef.current = () => {};
mo.disconnect();
window.clearTimeout(hideT);
if (raf) cancelAnimationFrame(raf);
};
}, []);
const bg = HEX.test(color) ? color : "#2563eb";
const ink = inkFor(bg);
const layer = {
position: "absolute",
left: 0,
top: 0,
opacity: 0,
whiteSpace: "nowrap",
} as const;
return (
<div
ref={rootRef}
className={"clf-root relative" + (hideCursor ? " clf-hide" : "") + (className ? " " + className : "")}
>
{children}
<div
aria-hidden
ref={posRef}
className="pointer-events-none absolute left-0 top-0 z-50"
style={{ transform: "translate3d(0,0,0)", willChange: "transform" }}
>
<style>{CSS}</style>
<div ref={rotRef} style={{ transform: "translate(-50%,-50%)" }}>
<div
ref={chipRef}
className="clf-chip"
style={{
display: "flex",
alignItems: "center",
height: 30,
padding: "0 11px 0 12px",
borderRadius: 999,
background: bg,
color: ink,
border: "1px solid " + (ink === "#ffffff" ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.14)"),
boxShadow: "0 10px 24px -8px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.22) inset",
overflow: "hidden",
whiteSpace: "nowrap",
fontFamily: MONO,
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.12em",
textTransform: "uppercase",
lineHeight: "14px",
opacity: 0,
transform: "scale(0.6)",
transition: "transform 0.34s cubic-bezier(0.34, 1.45, 0.5, 1), opacity 0.16s ease",
}}
>
<span
ref={slotRef}
className="clf-slot"
style={{ position: "relative", display: "block", flex: "none", width: 0, height: 14, transition: SLOT_T }}
>
<Glyphs />
</span>
<span
ref={tboxRef}
className="clf-tbox"
style={{ position: "relative", display: "block", flex: "none", width: 0, height: 14, transition: TBOX_T }}
>
<span ref={aRef} style={layer} />
<span ref={bRef} style={layer} />
<span ref={measRef} style={{ ...layer, visibility: "hidden" }} />
</span>
</div>
</div>
</div>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
A cursor chip that names what it is over carries information most custom cursors do not. On a portfolio grid or media gallery, where every tile looks clickable but they do not all do the same thing, it replaces a row of visible labels with one that appears where the pointer already is. One set of listeners on the wrapper reads the nearest data-cursor-label (and optional data-cursor-icon of view, drag, play, pause or arrow) from whatever is under the pointer, so adding a labelled element is an attribute and nothing else. The chip trails the pointer on a damped spring (stiffness is a prop; damping 30, mass 0.35) driven by requestAnimationFrame that writes one transform and sleeps once it settles, so pointer travel never re-renders React. Between targets the text slides and blurs through a crossfade while the pill and its glyph slot animate to the new width, and the chip tilts a few degrees with its velocity. Press shrinks it slightly. On touch there is no hover, so the label appears while a finger is down, lifted above the finger, and fades shortly after release. Keyboard focus (focus-visible) shows the chip centred on the focused target. Set hideCursor to turn the chip into the cursor itself, and pick any hex colour; text colour is chosen for contrast. The chip is decorative, so whatever it says must also exist in the accessible name of the thing it labels.
Curator’s note
Rebuilt in October with no dependencies: a hand-written spring, a morphing pill and real touch and keyboard paths. The delegated data-attribute lookup is still the part worth keeping.
