Following pointer label
Inside a card the cursor becomes a small arrow with a name pill that trails behind it with a soft lag.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef } from "react";
import { usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
export default function Demo() {
const card = useRef<HTMLElement>(null);
const follower = useRef<HTMLDivElement>(null);
const reduced = usePrefersReducedMotion();
useEffect(() => {
// Elements are read once here: a frame can still run after unmount, when refs are already null.
const [el, pill] = [card.current, follower.current];
if (!el || !pill || reduced) return;
const target = { x: 0, y: 0 };
const pos = { x: 0, y: 0 };
let raf = 0, hideTimer = 0;
// The pill trails the pointer with a soft lerp so it reads as attached but unhurried.
const frame = () => {
pos.x += (target.x - pos.x) * 0.16;
pos.y += (target.y - pos.y) * 0.16;
pill.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
raf = Math.hypot(target.x - pos.x, target.y - pos.y) > 0.1 ? requestAnimationFrame(frame) : 0;
};
const aim = (event: PointerEvent, snap: boolean) => {
const rect = el.getBoundingClientRect();
target.x = event.clientX - rect.left;
target.y = event.clientY - rect.top;
if (snap) Object.assign(pos, target);
el.classList.add(styles.active);
if (!raf) raf = requestAnimationFrame(frame);
};
const enter = (event: PointerEvent) => {
if (event.pointerType !== "touch") aim(event, true);
};
const move = (event: PointerEvent) => {
if (event.pointerType !== "touch") aim(event, false);
};
const leave = (event: PointerEvent) => {
if (event.pointerType !== "touch") el.classList.remove(styles.active);
};
// Touch: a tap shows the pill where the finger landed for a moment.
const down = (event: PointerEvent) => {
if (event.pointerType !== "touch") return;
aim(event, true);
clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => el.classList.remove(styles.active), 1600);
};
el.addEventListener("pointerenter", enter);
el.addEventListener("pointermove", move);
el.addEventListener("pointerleave", leave);
el.addEventListener("pointerdown", down);
return () => {
cancelAnimationFrame(raf);
clearTimeout(hideTimer);
el.removeEventListener("pointerenter", enter);
el.removeEventListener("pointermove", move);
el.removeEventListener("pointerleave", leave);
el.removeEventListener("pointerdown", down);
};
}, [reduced]);
return (
<div className={`${styles.root} ${reduced ? styles.still : ""}`}>
<article ref={card} className={styles.card}>
<div className={styles.cover} aria-hidden="true" />
<div className={styles.body}>
<span className={styles.tag}>Host stories</span>
<h3 className={styles.title}>How Maya filled 18 gap nights in March</h3>
<p className={styles.meta}>5 min read</p>
</div>
<div ref={follower} className={styles.follower} aria-hidden="true">
<svg className={styles.arrow} viewBox="0 0 16 16"><path d="M1 1l5.5 14 2-6 6-2z" /></svg>
<span className={styles.pill}><span className={styles.avatar} />Maya Ruiz</span>
</div>
</article>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; overflow: hidden; container-type: size; }
.card {
position: relative; width: min(100%, 420px); display: grid; grid-template-rows: auto auto; overflow: hidden;
border-radius: 16px; background: var(--white); border: 1px solid var(--line);
}
@media (pointer: fine) { .root:not(.still) .card { cursor: none; } }
.cover {
height: clamp(64px, 30cqh, 200px);
background:
radial-gradient(circle at 78% 30%, var(--sand) 0 12%, transparent 12.5%),
linear-gradient(var(--sky) 60%, var(--accent) 60% 72%, var(--accent-text) 72%);
}
.body { display: grid; gap: 6px; padding: 14px 18px 18px; }
.tag { justify-self: start; padding: 2px 9px; border-radius: 999px; background: var(--accent-pale); color: var(--accent-text); font-size: 12px; font-weight: 650; }
.title { margin: 0; font-size: clamp(15px, 3.4cqw, 19px); line-height: 1.3; font-weight: 650; letter-spacing: -0.01em; text-wrap: balance; }
.meta { font-size: 13px; color: var(--muted); }
.follower { position: absolute; left: 0; top: 0; pointer-events: none; opacity: 0; transition: opacity .2s; }
.active .follower { opacity: 1; }
.arrow { position: absolute; width: 16px; height: 16px; translate: -1px -1px; fill: var(--accent-text); stroke: var(--white); stroke-width: 1.2; stroke-linejoin: round; }
.pill {
position: absolute; left: 14px; top: 16px; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
padding: 4px 10px 4px 4px; border-radius: 999px; background: var(--accent-text); color: var(--white); font-size: 12px; font-weight: 650;
scale: .7; transform-origin: 0 0; transition: scale .3s var(--out);
}
.active .pill { scale: 1; }
.avatar { width: 18px; height: 18px; border-radius: 50%; background: linear-gradient(135deg, var(--sand), var(--accent-soft)); }
/* Reduced motion: the pill rests on the cover, no follow. */
.still .follower { opacity: 1; transform: translate(24px, 20px); }
.still .pill { scale: 1; }
@media (prefers-reduced-motion: reduce) { .follower, .pill { transition: none; } }
Use it for
- Blog or case study cards that credit an author
- Collaborative products, to hint at multiplayer cursors
Avoid
- Cards packed with buttons, since the pill covers them
- Lists of many small cards where the label flickers on and off
How it works
Pointer listeners sit on the card itself. Entering snaps the follower to the pointer and shows it; each move updates a target, and a requestAnimationFrame loop eases the follower toward it with a 0.16 lerp, stopping once it settles. The pill scales in from its top left corner so it grows out of the arrow, and leaving the card fades it out.
Accessibility
The native cursor is hidden only on fine pointers and only over the card. The follower is aria-hidden; the author name would also be in the card text in a real layout. Touch users see the pill where they tap for a moment. Reduced motion shows the pill resting on the cover with no follow.