A shared moodboard where the normal mouse pointer is replaced by a coloured arrow with a name tag, as in collaborative design tools. Your cursor follows the pointer on a tight spring, and its tag changes to say what a click will do: Open over a picture tile, Copy and the colour code over a swatch. Two teammates' cursors wander the board on looping paths. Before a real pointer arrives, your cursor takes a short tour by itself. On phones the board stacks and the teammates' cursors are hidden.
// Adapted from Magic UI (https://magicui.design/docs/components/pointer)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import type { PointerEvent } from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring } from "framer-motion";
/** Teammates whose cursors wander the board. Each path point is a fraction of the board (0 to 1). */
const teammates = [
{ name: "Noor", color: "#e11d48", path: [[0.2, 0.3], [0.34, 0.62], [0.12, 0.74], [0.2, 0.3]], seconds: 11 },
{ name: "Idris", color: "#0d9488", path: [[0.82, 0.72], [0.62, 0.36], [0.88, 0.24], [0.82, 0.72]], seconds: 14 },
];
/** Where your own cursor tours before the visitor moves a real pointer over the board. */
const tour = [[0.5, 0.82], [0.46, 0.42], [0.7, 0.56], [0.5, 0.82]];
const tiles = [
{ title: "Terracotta wall", note: "Entrance, north side", art: "from-orange-300 via-rose-300 to-rose-400", hint: "Open" },
{ title: "Linen and brass", note: "Table settings", art: "from-amber-100 via-stone-200 to-yellow-300", hint: "Open" },
{ title: "Evening terrace", note: "String lights, 40 seats", art: "from-indigo-400 via-violet-400 to-fuchsia-300", hint: "Open" },
];
const swatches = [
{ hex: "#C2410C", name: "Kiln" },
{ hex: "#F5E6CC", name: "Linen" },
{ hex: "#3F6212", name: "Olive" },
{ hex: "#1E1B4B", name: "Dusk" },
];
/** The arrow and its name tag. Position it with a transform on the wrapper. */
function Cursor({ color, label }: { color: string; label: string }) {
return (
<>
<svg viewBox="0 0 20 20" className="h-6 w-6 drop-shadow-md" aria-hidden="true">
<path d="M3 2.500l13.500 5.700-5.900 1.900-2 5.900z" fill={color} stroke="#fff" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
<span className="ml-5 -mt-1 block whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-semibold text-white shadow-md" style={{ backgroundColor: color }}>
{label}
</span>
</>
);
}
export default function CustomCursorPointerWithNameLabels() {
const reduce = useReducedMotion();
const board = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 960, h: 520 });
const [active, setActive] = useState(false);
const [hint, setHint] = useState("");
const [copied, setCopied] = useState("");
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, { stiffness: 700, damping: 40, mass: 0.3 });
const sy = useSpring(y, { stiffness: 700, damping: 40, mass: 0.3 });
useEffect(() => {
const el = board.current;
if (!el) return;
const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight });
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const follow = (e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
x.set(e.clientX - r.left);
y.set(e.clientY - r.top);
if (!active) {
sx.jump(e.clientX - r.left);
sy.jump(e.clientY - r.top);
setActive(true);
}
};
const roam = (path: number[][], seconds: number) =>
reduce
? { x: path[1][0] * size.w, y: path[1][1] * size.h }
: {
x: path.map((p) => p[0] * size.w),
y: path.map((p) => p[1] * size.h),
transition: {
x: { duration: seconds, repeat: Infinity, ease: "easeInOut" as const },
y: { duration: seconds, repeat: Infinity, ease: "easeInOut" as const },
opacity: { duration: 0.3 },
},
};
const zone = (label: string) => ({ onPointerEnter: () => setHint(label), onPointerLeave: () => setHint("") });
return (
<section className="bg-slate-100 px-4 py-12 sm:px-6 sm:py-16">
<div className="mx-auto max-w-5xl">
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-widest text-indigo-600">Custom cursors</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl">Everyone on the board, by name</h2>
</div>
<p className="max-w-xs text-sm text-slate-600">Move over the board: your pointer becomes a named cursor and tells you what a click will do.</p>
</div>
<div
ref={board}
onPointerMove={follow}
onPointerDown={follow}
onPointerLeave={() => { setActive(false); setHint(""); }}
className="relative cursor-none touch-pan-y overflow-hidden rounded-3xl bg-white shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/10 [&_*]:cursor-none"
>
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4">
<div className="min-w-0">
<p className="truncate font-semibold text-slate-900">Casa Olmo, opening night</p>
<p className="text-xs text-slate-600">Moodboard, edited just now</p>
</div>
<ul className="flex -space-x-2" aria-label="People on this board">
{[{ n: "You", c: "#4f46e5", i: "Y" }, ...teammates.map((t) => ({ n: t.name, c: t.color, i: t.name[0] }))].map((p) => (
<li key={p.n} className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold text-white ring-2 ring-white" style={{ backgroundColor: p.c }}>
<span aria-hidden="true">{p.i}</span>
<span className="sr-only">{p.n}</span>
</li>
))}
</ul>
</div>
<div className="grid gap-4 bg-[radial-gradient(#cbd5e1_1px,transparent_1px)] p-5 [background-size:20px_20px] sm:grid-cols-3 sm:p-7">
{tiles.map((t) => (
<button key={t.title} type="button" {...zone(t.hint)} className="group rounded-2xl bg-white p-2.5 text-left shadow-sm ring-1 ring-slate-900/10 transition-shadow hover:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600">
<span className={`block h-32 rounded-xl bg-gradient-to-br sm:h-40 ${t.art}`} aria-hidden="true" />
<span className="mt-3 block px-1 text-sm font-semibold text-slate-900">{t.title}</span>
<span className="block px-1 pb-1 text-xs text-slate-600">{t.note}</span>
</button>
))}
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-slate-900/10 sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-600">Palette</p>
<ul className="mt-3 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
{swatches.map((s) => (
<li key={s.hex}>
<button
type="button"
{...zone(`Copy ${s.hex}`)}
onClick={() => setCopied(s.hex)}
className="flex w-full items-center gap-2.5 rounded-xl p-1.5 text-left ring-1 ring-slate-900/10 transition-colors hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600"
>
<span className="h-9 w-9 flex-none rounded-lg ring-1 ring-black/10" style={{ backgroundColor: s.hex }} aria-hidden="true" />
<span className="min-w-0">
<span className="block text-sm font-medium text-slate-900">{s.name}</span>
<span className="block font-mono text-[11px] text-slate-600">{s.hex}</span>
</span>
</button>
</li>
))}
</ul>
<p role="status" className="mt-3 h-4 text-xs font-medium text-emerald-700">{copied ? `${copied} selected for the brief` : ""}</p>
</div>
<div className="-rotate-2 rounded-2xl bg-yellow-200 p-4 shadow-md">
<p className="text-sm font-medium leading-snug text-yellow-950">Florist confirms Thursday. Ask about olive branches for the long table.</p>
<p className="mt-3 text-xs text-yellow-900">Noor, 10:42</p>
</div>
</div>
{/* Other people's cursors: decoration, moved along a looping path. */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden sm:block">
{teammates.map((t) => (
<motion.div key={t.name} className="absolute left-0 top-0" animate={roam(t.path, t.seconds)}>
<Cursor color={t.color} label={t.name} />
</motion.div>
))}
</div>
{/* Your cursor: follows the real pointer, or tours the board until there is one. */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 z-20">
<AnimatePresence>
{active ? (
<motion.div key="you" className="absolute left-0 top-0" style={{ x: sx, y: sy }}>
<motion.div initial={{ scale: 0, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0, opacity: 0 }} transition={{ type: "spring", stiffness: 500, damping: 28 }} className="origin-top-left">
<Cursor color="#4f46e5" label={hint || "You"} />
</motion.div>
</motion.div>
) : (
<motion.div key="tour" className="absolute left-0 top-0" initial={{ opacity: 0 }} animate={{ opacity: 1, ...roam(tour, 9) }} exit={{ opacity: 0 }}>
<Cursor color="#4f46e5" label="You" />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/custom-cursor-pointer-with-name-labels.json
A pointer move handler on the board works out the position inside the board and writes it to two motion values. Those feed springs, and the cursor element is moved with a transform. Nothing is rendered again by React while the pointer moves.
A CSS cursor image is limited in size, cannot animate and cannot carry live text. Drawing the cursor as an element lets it hold a name tag, change its label and spring into place.
It can if the replacement lags or fails to appear. Here the first move jumps the spring straight to the pointer, so there is no moment without a cursor, and the hidden area is limited to the board. Avoid hiding the cursor over text fields.
There is no hovering pointer on touch, so the cursor continues its tour and appears under a finger while it is down. All controls work by tap as usual.
Replace the looping `animate` paths with positions from your realtime service, and set each teammate's motion values when an update arrives. The `Cursor` drawing stays the same.
Adapted from Pointer in Magic UI, used under its open-source licence. Licences and credits
Related: Magnetic Button That Follows the Pointer, Spotlight Cards with Pointer Light, Dot Matrix Background with Ripples and Pointer Spotlight.
More cursor effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.