Live cursors
Other people’s cursors gliding over the same page: names that turn into speech bubbles, click ripples, and selections that follow them around.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/live-cursors.jsonOverview
Multiplayer is the feature people screenshot, and most cursor overlays step from one network update to the next. Here every cursor eases toward its latest position on a spring, so ten updates a second look like a hand moving. Each one carries a pill with the person’s name and colour; near the right or bottom edge the pill swings to the other side so it stays in view. When someone says something the pill grows into a bubble around their words and folds back when they’re done. Clicks send a hairline ring out from the tip, the thing someone is working on gets a 1px outline in their colour that glides to whatever they pick next, and a cursor that hasn’t moved in a while folds its name into the dot and dims until it moves again. Your own side is included: press / and a bubble opens at your pointer.
States
- move
- A spring (stiffness 150, damping 24) per cursor, run in one requestAnimationFrame loop that writes transforms directly and stops once everyone has settled. Someone new appears where they are.
- say
- The name pill eases its width and height (360ms) to hold the message, which blurs in (300ms); clearing it folds the bubble back to the pill.
- click
- A 36px hairline ring in their colour scales from 0.3 to 1 and fades (620ms).
- select
- A 1px outline with a name tag; its box eases to the next selection (420ms) and fades when cleared.
- idle / leave
- After idleAfter without moving, the name folds into its dot and the cursor dims to 55%. Someone who leaves shrinks and fades (260ms).
- you
- With onMessage, "/" opens a bubble at your pointer that follows it; Enter sends, Escape or clicking away closes. What you sent stays for 4 seconds.
Usage
import { LiveCursors } from "@/components/live-cursors";
<LiveCursors
cursors={[
{ id: "ana", name: "Ana", x: 0.42, y: 0.3, message: "Love this headline" },
{ id: "kofi", name: "Kofi", x: 0.7, y: 0.8, selection: { x: 0.36, y: 0.72, width: 0.28, height: 0.21 } },
]}
>
<Canvas />
</LiveCursors>Works with
With a realtime channel
Send your pointer (already in 0–1 units) on every move, and pass everyone else’s straight back in; the springs smooth whatever rate the channel manages.
"use client";
import { useEffect, useState } from "react";
import { LiveCursors, type LiveCursor } from "@/components/live-cursors";
import { channel, me } from "@/lib/realtime"; // Liveblocks, PartyKit, Supabase Realtime…
export function Room({ children }: { children: React.ReactNode }) {
const [others, setOthers] = useState<Record<string, LiveCursor>>({});
useEffect(
() =>
channel.subscribe((event) => {
setOthers((all) => {
if (event.type === "leave") {
const { [event.id]: _, ...rest } = all;
return rest;
}
return { ...all, [event.id]: { ...all[event.id], ...event.cursor } };
});
}),
[],
);
return (
<LiveCursors
cursors={Object.values(others)}
onCursorMove={(point) => channel.send({ type: point ? "move" : "leave", id: me.id, cursor: { name: me.name, ...point } })}
onMessage={(message) => channel.send({ type: "move", id: me.id, cursor: { message } })}
>
{children}
</LiveCursors>
);
}Props
| Prop | Type | Description |
|---|---|---|
| cursors | { id, name, x, y, color?, message?, clicks?, selection? }[] | Everyone else. x and y run from 0 to 1 across the area; raise clicks by one per click; selection is a box in the same units. |
| children | ReactNode | What they’re all looking at; the cursors float over it. |
| idleAfter | numberdefault 8000 | Milliseconds without moving before a name folds into its dot. |
| onCursorMove | (point: { x, y } | null) => void | Your own mouse over the area in 0–1 units, or null when it leaves. |
| onMessage | (text: string) => void | Turns on "/" to say something at your pointer (mouse and trackpad only). |
Accessibility and motion
- People take the five chart colours in the order they first appear and keep them; pass color to choose your own.
- The cursors are decoration to assistive tech; what people say is announced through a polite status region, once per message.
- Positions are fractions, so the same numbers work on a phone and a wide screen as long as what’s underneath scales the same way.
- "/" is only taken while your pointer is over the area, so a page’s own "/" shortcut (search, usually) keeps working everywhere else.
- The loop sleeps when nobody is moving. With reduced motion cursors jump to where they are and bubbles change size in place.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
Source
›Show the full source of live-cursors.tsx (501 lines)
"use client";
import * as React from "react";
/* ─────────────────────────────────────────────────────────
* LIVE CURSORS: other people, moving in the same place
*
* move positions arrive whenever your realtime layer sends
* them (ten a second is plenty); each cursor eases
* toward the latest on a spring, so it glides instead
* of stepping
* name a pill hangs off each cursor with its colour; near
* the right or bottom edge it swings to the other side
* so it stays inside
* say a message grows the pill into a bubble (one surface,
* size and corners easing together) and folds it back
* when it's cleared
* click a hairline ring ripples out from the tip
* select the box someone is working on gets a 1px outline in
* their colour that glides to whatever they pick next
* idle after a while without moving, the name folds into
* the dot and the cursor dims; it comes back the moment
* they move
* you with onMessage, press / and a bubble opens at your
* own pointer; Enter sends it, Escape closes it
* ───────────────────────────────────────────────────────── */
export interface LiveCursorBox {
x: number;
y: number;
width: number;
height: number;
}
export interface LiveCursor {
id: string;
name: string;
/** Position from 0 to 1 across and down the area. */
x: number;
y: number;
/** Any CSS colour; people take the chart colours in order by default. */
color?: string;
/** What they're saying right now; clear it to fold the bubble. */
message?: string;
/** Raise it by one for each click to show a ripple. */
clicks?: number;
/** The box they're working on, in the same 0–1 units. */
selection?: LiveCursorBox | null;
}
export interface LiveCursorsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
cursors: LiveCursor[];
children?: React.ReactNode;
/** Milliseconds without moving before a cursor folds its name away. */
idleAfter?: number;
/** Your pointer over the area, 0–1, or null when it leaves; send it to the others. */
onCursorMove?: (point: { x: number; y: number } | null) => void;
/** Turns on "/" to say something at your pointer; called with what you send. */
onMessage?: (text: string) => void;
}
const EASE = "cubic-bezier(0.16,1,0.3,1)";
const PALETTE = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"];
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
const finePointer = "(pointer: fine)";
const subscribeFine = (onChange: () => void) => {
const query = window.matchMedia(finePointer);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useFinePointer = () => React.useSyncExternalStore(subscribeFine, () => window.matchMedia(finePointer).matches, () => false);
const editable = (el: Element | null) =>
!!el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement).isContentEditable);
/** A box that eases to the size of what's inside it, so a pill can grow into a bubble. */
function Shape({ children, className, style, reduced }: { children: React.ReactNode; className: string; style?: React.CSSProperties; reduced: boolean }) {
const inner = React.useRef<HTMLDivElement>(null);
const [size, setSize] = React.useState<{ w: number; h: number } | null>(null);
React.useLayoutEffect(() => {
const el = inner.current;
if (!el) return;
const measure = () => setSize({ w: el.offsetWidth, h: el.offsetHeight });
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
className={`overflow-hidden ${className}`}
style={{
...style,
width: size?.w,
height: size?.h,
transition: reduced || !size ? "none" : `width 360ms ${EASE}, height 360ms ${EASE}, opacity 240ms`,
}}
>
<div ref={inner} className="w-max">
{children}
</div>
</div>
);
}
/** A hairline ring rippling out from a cursor's tip; it removes itself when done. */
function Ripple({ color, onDone }: { color: string; onDone: () => void }) {
const ref = React.useRef<HTMLSpanElement>(null);
const done = React.useRef(onDone);
done.current = onDone;
React.useEffect(() => {
const animation = ref.current?.animate(
[
{ transform: "translate(-50%, -50%) scale(0.3)", opacity: 0.9 },
{ transform: "translate(-50%, -50%) scale(1)", opacity: 0 },
],
{ duration: 620, easing: EASE, fill: "forwards" },
);
if (!animation) return done.current();
animation.onfinish = () => done.current();
return () => animation.cancel();
}, []);
return <span ref={ref} className="absolute left-0 top-0 size-9 rounded-full border" style={{ borderColor: color }} />;
}
/** A message that blurs in when it changes. */
function Said({ text }: { text: string }) {
const ref = React.useRef<HTMLParagraphElement>(null);
React.useEffect(() => {
ref.current?.animate([{ opacity: 0, filter: "blur(4px)" }, { opacity: 1, filter: "blur(0px)" }], { duration: 300, delay: 120, easing: EASE, fill: "backwards" });
}, [text]);
return (
<p ref={ref} className="mt-0.5 max-w-[200px] whitespace-normal text-[12px] leading-snug text-foreground">
{text}
</p>
);
}
export function LiveCursors({
cursors,
children,
idleAfter = 8000,
onCursorMove,
onMessage,
className = "",
...props
}: LiveCursorsProps) {
const reduced = useReducedMotion();
const fine = useFinePointer();
const rootRef = React.useRef<HTMLDivElement>(null);
const els = React.useRef(new Map<string, HTMLDivElement>());
const sim = React.useRef(new Map<string, { x: number; y: number; vx: number; vy: number }>());
const targets = React.useRef(new Map<string, { x: number; y: number }>());
const area = React.useRef({ w: 0, h: 0 });
const frame = React.useRef(0);
const reducedRef = React.useRef(reduced);
reducedRef.current = reduced;
// Colour follows the person: each new id takes the next colour and keeps it.
const slots = React.useRef(new Map<string, number>());
const colorOf = (c: LiveCursor) => {
if (c.color) return c.color;
if (!slots.current.has(c.id)) slots.current.set(c.id, slots.current.size);
return PALETTE[slots.current.get(c.id)! % PALETTE.length];
};
// People who just left stay a moment to fade out.
const [leaving, setLeaving] = React.useState<LiveCursor[]>([]);
const previous = React.useRef<LiveCursor[]>([]);
const timers = React.useRef(new Set<number>());
React.useEffect(() => {
const now = new Set(cursors.map((c) => c.id));
const gone = previous.current.filter((c) => !now.has(c.id));
previous.current = cursors;
if (!gone.length) return;
setLeaving((l) => [...l.filter((c) => !now.has(c.id)), ...gone]);
// Not cleared when cursors update again (they do, ten times a second); only on unmount.
const timer = window.setTimeout(() => {
timers.current.delete(timer);
setLeaving((l) => l.filter((c) => !gone.some((g) => g.id === c.id)));
gone.forEach((g) => {
if (previous.current.some((c) => c.id === g.id)) return; // they came back
sim.current.delete(g.id);
targets.current.delete(g.id);
});
}, 320);
timers.current.add(timer);
}, [cursors]);
React.useEffect(() => {
const all = timers.current;
return () => all.forEach((t) => window.clearTimeout(t));
}, []);
// One loop moves every cursor toward its target on a spring, writing transforms directly.
const kick = React.useCallback(() => {
if (frame.current) return;
let last = performance.now();
const loop = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
let busy = false;
for (const [id, t] of targets.current) {
const tx = t.x * area.current.w;
const ty = t.y * area.current.h;
let s = sim.current.get(id);
if (!s) sim.current.set(id, (s = { x: tx, y: ty, vx: 0, vy: 0 }));
if (reducedRef.current) Object.assign(s, { x: tx, y: ty, vx: 0, vy: 0 });
else {
s.vx += (150 * (tx - s.x) - 24 * s.vx) * dt;
s.vy += (150 * (ty - s.y) - 24 * s.vy) * dt;
s.x += s.vx * dt;
s.y += s.vy * dt;
if (Math.abs(tx - s.x) + Math.abs(ty - s.y) > 0.3 || Math.abs(s.vx) + Math.abs(s.vy) > 2) busy = true;
else Object.assign(s, { x: tx, y: ty, vx: 0, vy: 0 });
}
const el = els.current.get(id);
if (el) el.style.transform = `translate3d(${s.x}px, ${s.y}px, 0)`;
}
frame.current = busy ? requestAnimationFrame(loop) : 0;
};
frame.current = requestAnimationFrame(loop);
}, []);
React.useEffect(
() => () => {
cancelAnimationFrame(frame.current);
frame.current = 0; // so a remount can start the loop again
},
[],
);
React.useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
const measure = () => {
area.current = { w: root.clientWidth, h: root.clientHeight };
kick();
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, [kick]);
// New targets; somebody new appears where they are rather than flying in from a corner.
const moved = React.useRef(new Map<string, number>());
React.useLayoutEffect(() => {
const now = Date.now();
for (const c of cursors) {
const t = targets.current.get(c.id);
if (!t || t.x !== c.x || t.y !== c.y) moved.current.set(c.id, now);
targets.current.set(c.id, { x: c.x, y: c.y });
if (!sim.current.has(c.id)) {
const s = { x: c.x * area.current.w, y: c.y * area.current.h, vx: 0, vy: 0 };
sim.current.set(c.id, s);
const el = els.current.get(c.id);
if (el) el.style.transform = `translate3d(${s.x}px, ${s.y}px, 0)`;
}
}
kick();
}, [cursors, kick]);
// Idle: fold the name away after a while without moving.
const [away, setAway] = React.useState<Set<string>>(() => new Set());
React.useEffect(() => {
const check = () => {
const now = Date.now();
const next = new Set(cursors.filter((c) => now - (moved.current.get(c.id) ?? now) > idleAfter && !c.message).map((c) => c.id));
setAway((a) => (a.size === next.size && [...next].every((id) => a.has(id)) ? a : next));
};
check();
const timer = window.setInterval(check, 1000);
return () => window.clearInterval(timer);
}, [cursors, idleAfter]);
// Clicks ripple from the tip.
const [ripples, setRipples] = React.useState<{ id: string; key: number }[]>([]);
const clicks = React.useRef(new Map<string, number>());
React.useEffect(() => {
const fresh: { id: string; key: number }[] = [];
for (const c of cursors) {
const seen = clicks.current.get(c.id);
if (seen !== undefined && (c.clicks ?? 0) > seen) fresh.push({ id: c.id, key: Math.random() });
clicks.current.set(c.id, c.clicks ?? 0);
}
if (fresh.length && !reduced) setRipples((r) => [...r, ...fresh]);
}, [cursors, reduced]);
// Messages are announced politely, once each.
const [announce, setAnnounce] = React.useState("");
const said = React.useRef(new Map<string, string>());
React.useEffect(() => {
for (const c of cursors) {
if (c.message && said.current.get(c.id) !== c.message) setAnnounce(`${c.name}: ${c.message}`);
said.current.set(c.id, c.message ?? "");
}
}, [cursors]);
// You: your pointer goes out through onCursorMove, and "/" opens a bubble that follows it.
const pointer = React.useRef<{ x: number; y: number } | null>(null);
const selfRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const [chat, setChat] = React.useState<{ open: boolean; text: string; sent: string }>({ open: false, text: "", sent: "" });
const chatting = chat.open || Boolean(chat.sent);
const place = () => {
const p = pointer.current;
if (p && selfRef.current) selfRef.current.style.transform = `translate3d(${p.x}px, ${p.y}px, 0)`;
};
React.useEffect(() => {
if (!onMessage || !fine) return;
// Caught on the way down, and only while the pointer is over the area, so a page's own "/" shortcut
// (search, usually) still works everywhere else.
const onKey = (e: KeyboardEvent) => {
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey || !pointer.current || chat.open || editable(document.activeElement)) return;
e.preventDefault();
e.stopImmediatePropagation();
setChat({ open: true, text: "", sent: "" });
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [onMessage, fine, chat.open]);
React.useLayoutEffect(() => {
place();
if (chat.open) inputRef.current?.focus({ preventScroll: true });
}, [chat.open]);
React.useEffect(() => {
if (!chat.sent) return;
const timer = window.setTimeout(() => setChat((c) => ({ ...c, sent: "" })), 4000);
return () => window.clearTimeout(timer);
}, [chat.sent]);
const all = [...cursors.map((c) => ({ c, gone: false })), ...leaving.filter((l) => !cursors.some((c) => c.id === l.id)).map((c) => ({ c, gone: true }))];
return (
<div
ref={rootRef}
className={`relative ${className}`}
onPointerMove={(e) => {
if (e.pointerType !== "mouse") return;
const r = e.currentTarget.getBoundingClientRect();
pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top };
place();
onCursorMove?.({ x: pointer.current.x / r.width, y: pointer.current.y / r.height });
}}
onPointerLeave={(e) => {
if (e.pointerType !== "mouse") return;
pointer.current = null;
onCursorMove?.(null);
}}
{...props}
>
{children}
{/* What others have selected: a hairline box that glides to their next pick. */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
{all.map(({ c, gone }) =>
c.selection ? (
<div
key={`sel-${c.id}`}
className="absolute rounded-[6px] border"
style={{
left: `${c.selection.x * 100}%`,
top: `${c.selection.y * 100}%`,
width: `${c.selection.width * 100}%`,
height: `${c.selection.height * 100}%`,
borderColor: colorOf(c),
opacity: gone ? 0 : 1,
transition: reduced ? "none" : `left 420ms ${EASE}, top 420ms ${EASE}, width 420ms ${EASE}, height 420ms ${EASE}, opacity 240ms`,
}}
>
<span
className="absolute left-2 top-0 flex -translate-y-1/2 items-center gap-1 rounded-full border bg-popover px-1.5 text-[10.5px] font-medium leading-4 text-foreground"
style={{ borderColor: colorOf(c) }}
>
<span className="size-1.5 rounded-full" style={{ backgroundColor: colorOf(c) }} />
{c.name}
</span>
</div>
) : null,
)}
</div>
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
{all.map(({ c, gone }) => {
const color = colorOf(c);
const left = c.x > 0.72;
const up = c.y > 0.82;
const folded = away.has(c.id) && !gone;
return (
<div
key={c.id}
ref={(el) => {
if (!el) return void els.current.delete(c.id);
els.current.set(c.id, el);
const s = sim.current.get(c.id);
if (s) el.style.transform = `translate3d(${s.x}px, ${s.y}px, 0)`;
}}
className="absolute left-0 top-0 will-change-transform"
>
<div
style={{
opacity: gone ? 0 : folded ? 0.55 : 1,
transform: gone ? "scale(0.6)" : "none",
transformOrigin: "0 0",
transition: reduced ? "none" : `opacity 260ms ${EASE}, transform 260ms ${EASE}`,
}}
>
{ripples
.filter((r) => r.id === c.id)
.map((r) => (
<Ripple key={r.key} color={color} onDone={() => setRipples((all) => all.filter((x) => x.key !== r.key))} />
))}
<svg width="18" height="18" viewBox="0 0 18 18" className="absolute -left-px -top-px overflow-visible">
<path d="M1.5 1.5 L15 7.6 L8.9 9.3 L6.4 15.6 Z" fill={color} stroke="var(--background)" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
{/* The name pill: swings to the other side near an edge, grows into a bubble to say something. */}
<div
className="absolute"
style={{
left: left ? -4 : 12,
top: up ? -8 : 18,
transform: `translate(${left ? "-100%" : "0"}, ${up ? "-100%" : "0"})`,
transition: reduced ? "none" : `left 300ms ${EASE}, top 300ms ${EASE}, transform 300ms ${EASE}`,
}}
>
<Shape
reduced={reduced}
className="border bg-popover"
style={{ borderColor: color, borderRadius: 12 }}
>
<div className={c.message ? "px-2.5 py-1.5" : "px-1.5 py-[3px]"}>
<div className="flex items-center gap-1.5">
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: color }} />
{!folded && <span className="pr-0.5 text-[11.5px] font-medium leading-4 text-foreground">{c.name}</span>}
</div>
{c.message && !gone && <Said text={c.message} />}
</div>
</Shape>
</div>
</div>
</div>
);
})}
</div>
{onMessage && fine && (
<div ref={selfRef} className="pointer-events-none absolute left-0 top-0 z-10" aria-hidden={!chatting}>
<div
className="absolute left-4 top-5"
style={{
opacity: chatting ? 1 : 0,
transform: chatting ? "none" : "scale(0.8)",
transformOrigin: "0 0",
transition: reduced ? "none" : `opacity 200ms ${EASE}, transform 260ms ${EASE}`,
}}
>
<Shape reduced={reduced} className="rounded-[12px] border border-border bg-popover">
<div className="px-2.5 py-1.5">
{chat.open ? (
<input
ref={inputRef}
value={chat.text}
aria-label="Say something"
placeholder="Say something"
maxLength={120}
onChange={(e) => setChat((c) => ({ ...c, text: e.target.value }))}
onBlur={() => setChat((c) => (c.open ? { open: false, text: "", sent: "" } : c))}
onKeyDown={(e) => {
if (e.key === "Escape") setChat({ open: false, text: "", sent: "" });
if (e.key !== "Enter") return;
const text = chat.text.trim();
setChat({ open: false, text: "", sent: text });
if (text) onMessage(text);
}}
className="pointer-events-auto bg-transparent text-[12.5px] text-foreground outline-none placeholder:text-muted-foreground"
style={{ width: `${Math.min(28, Math.max(12, chat.text.length + 1))}ch` }}
/>
) : (
<p className="max-w-[220px] text-[12.5px] leading-snug text-foreground">{chat.sent}</p>
)}
</div>
</Shape>
</div>
</div>
)}
<p className="sr-only" role="status">
{announce}
</p>
</div>
);
}
Registry item: /r/live-cursors.json