Live Cursors
A multiplayer cursor overlay — labelled peer pointers interpolated between low-rate updates, chart-token colour per peer, idle fade, relayed click ripples and a spoken roster, with the transport left to you.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/live-cursors.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "LiveCursors" component: a multiplayer
cursor layer that owns NO transport. Only dependency is a `cn` class merger —
the interpolation, the pooling and the presence bookkeeping are the product.
Contract
- export const LiveCursors = React.forwardRef<HTMLDivElement, LiveCursorsProps>,
remaining props spread onto the root, className merged with cn():
peers: LiveCursorPeer[] — { id: string; name: string; x: number; y: number;
color?: string; clickId?: string | number }. x/y are NORMALISED 0..1 of the
overlay box, never pixels, so two clients of different sizes agree.
selfId?: string — most relays echo you back; that row is dropped, not drawn.
maxPeers?: number (10) — how many cursors may be painted at once.
followMs?: number (90) — smoothing time constant. 0 snaps.
snapDistance?: number (0.4) — a wider gap than this is a teleport, not motion.
trail?: number (4, max 6) — trailing dots; forced to 0 under reduced motion.
idleAfterMs?: number (6000) — stillness before a peer counts as idle;
Infinity switches the whole idle path off.
idleFadeMs?: number (900), idleOpacity?: number (0.3).
rippleMs?: number (650), showNames?: boolean (true), announce?: boolean (true).
broadcastHz?: number (4) with onLocalMove?(point), onLocalClick?(point) and
onLocalLeave?() — the outbound half. They report; they never send.
onPresenceChange?: (present: LiveCursorPresence[]) => void — { id, name,
color, idle }, fired only when the drawn roster actually changes.
- Also export LiveCursorPoint / LiveCursorPeer / LiveCursorPresence.
Behavior
- Sanitise before anything else, because bad rows here are permanent: drop a row
whose id is missing, empty, equal to selfId, or already seen (a duplicate id
would key two DOM nodes the same and make both flicker); drop non-finite x/y (a
single NaN writes "translate3d(NaNpx…)" for the rest of the session); clamp
x/y into 0..1; a blank name becomes "Guest". Peers past maxPeers are not
drawn but ARE still counted by the roster.
- Colour: peer.color wins; otherwise hash the id with FNV-1a (Math.imul, >>> 0
so a long id cannot wrap negative) into var(--chart-1..5). Same person, same
colour, on every client and on the server. Five tokens means colours repeat
past five peers — the name flag carries identity, colour only groups.
- Interpolation is the whole point. A 4 Hz feed is 250 ms between samples; drawn
raw it reads as stepping. Per frame: alpha = 1 - e^(-dt/followMs), then
render += (target - render) * alpha. Using dt (not a fixed step) makes 60Hz
and 144Hz produce the same path. The delay budget you are spending:
transport staleness averages HALF an update interval (125 ms at 4 Hz) and the
smoother adds its own steady-state lag of about followMs, so ~215 ms at the
defaults. Keep followMs near 0.35-0.45 of the update interval: at 90 ms vs
250 ms the cursor covers 1 - e^(-250/90) = 94% of the gap and arrives just as
the next sample lands. Much smaller and it arrives early and freezes (visible
stutter); much larger and it never catches up and every peer feels drunk.
- Discontinuities are not animated: first placement, a gap wider than
snapDistance (page jump, camera change, a peer teleporting), and the first
frame after the loop resumes all PLACE the cursor and collapse its trail,
instead of sliding it across the surface.
- Idle: measure stillness on the ANIMATION clock (the rAF timestamp), never on
a clock read during render — the component must be renderable on a server and
in a test at a fixed instant. When a peer's target changes, flag it and let
the next frame stamp the time. fade = clamp((still - idleAfterMs)/idleFadeMs,
0, 1), opacity = 1 - fade * (1 - idleOpacity). Crossing the threshold (not the
fade) is the only thing that reaches React state, so a room of ten peers
causes zero renders while everyone is moving and one render when someone
stops.
- Click ripples arrive as a CHANGE TOKEN, not a timestamp: any clickId that
differs from the one last seen spawns one ripple at that peer's reported
position. The first value a peer arrives with is a snapshot from before it
joined and is stored without rippling, or every join and every remount would
flash. Ripples come from a fixed pool of 8 nodes (a free slot, else the oldest
one), so a click storm recycles instead of allocating.
- Trails are a preallocated ring buffer per peer: sample the render position
every 45 ms of animation time, dot i reads i samples back, so 4 dots span
~180 ms of history. No arrays are created per frame.
- Outbound local capture listens on the overlay's PARENT element, because the
overlay is pointer-events-none by contract and can never be a pointer target
itself. pointermove only writes {clientX, clientY} to a ref; the rAF loop
converts it against the box and calls onLocalMove at most broadcastHz times a
second (sample-and-hold: the newest position wins, nothing queues, and a move
under 0.0015 of the box is not re-sent). pointerdown reports immediately;
pointerleave/pointercancel clears the held sample and calls onLocalLeave, so
your ghost does not sit in the room forever. Wire nothing and no listener is
attached at all.
- Rendering path: peers reach the DOM through one rAF loop that writes transform
and opacity to the nodes directly. React renders only for join / leave /
rename / idle-flip. Two rules make that safe: every group mounts parked at
translate3d(-9999px,…) so a joining peer never paints a frame at the origin,
and the transform written in JSX is a CONSTANT, so React's style diff never
overwrites what the loop wrote.
- The loop earns its keep by stopping: it parks itself once every cursor has
settled AND finished fading (nothing is pending, so nothing needs a frame),
and it is woken by the next props change, resize or pointermove. An
IntersectionObserver and visibilitychange pause it entirely off-screen or in a
hidden tab — and because time only passes while it runs, nobody drifts into
idle behind the user's back; the first frame back places every cursor and
restarts the idle clocks.
- Sizing: a ResizeObserver on the root supplies the box; positions are only
written once it has one, so nothing paints at (0,0) before measurement.
- Accessibility, keyboard and focus: there is none, deliberately. The painted
layer is aria-hidden and pointer-events-none — it is a picture of other
people's pointers — and nothing inside is focusable, so nothing can go inert
under the user and there is no successor to hand focus to. The accessible
surface is a visually-hidden role="status" aria-live="polite" aria-atomic
sentence: "3 people here: Ada Lovelace, Grace Hopper (idle), Alan Turing." /
"No one else is here." / "… 2 more not shown." announce={false} drops the
live region and keeps the text. Positions are just props, so a keyboard-driven
or caret-driven cursor draws exactly like a mouse-driven one — the gesture is
never the only path in.
- Reduced motion: positions jump instead of gliding, trails are not rendered at
all, the idle fade becomes a step and a ripple is a ring that simply exists for
its lifetime instead of expanding. Everything the overlay communicates —
who is here, where they are, who went quiet, who clicked — survives with the
motion off.
- Cleanup: the rAF, the ResizeObserver, the IntersectionObserver, the
visibilitychange listener, all four pointer listeners and the wake thunk are
released on unmount; the pointer listeners are also torn down and re-attached
when the outbound handlers appear or disappear.
Rendering & styling
- Semantic tokens only, no hex anywhere. Peer colour rides a --lc-tint custom
property set on each group, sourced from var(--chart-1..5); the arrow is
filled with it and outlined with stroke-background (that outline is what keeps
a cursor readable over a photo, a dark canvas or another cursor); the flag is
color-mix(in oklab, var(--lc-tint) 16%, var(--card)) with a border in the tint
and text-card-foreground; the ripple is border-current.
- The flag is tinted rather than solid on purpose: the chart tokens are tuned
for 3:1 against a card, which is the threshold for shapes, not for 11px text.
- Near the right edge the flag would be clipped, so the group carries a
data-edge attribute (written only when the side changes) and one CSS variant
flips the flag to the other side of the pointer.
- The root is absolute inset-0 pointer-events-none select-none; mount it inside
the positioned surface it covers.
Customization levers
- Feel: followMs and broadcastHz are one decision, not two — read the delay
budget above before changing either. snapDistance decides what counts as a
teleport (lower it on a canvas that pans). trail is 0..6 and TRAIL_SAMPLE_MS
(45) sets how much history a tail spans.
- Idle: the triple idleAfterMs / idleFadeMs / idleOpacity covers everything from
"never dim" (Infinity) to "vanish after two seconds" (idleOpacity 0). Add a
second threshold in the same place if you also want an "away" tier.
- Density: showNames={false} leaves bare pointers for a crowded canvas;
maxPeers trades completeness for legibility and the roster keeps counting the
rest. Want the classic Figma solid chip? Set the flag background to the tint
and the text to text-background — and check the contrast, chart-1 on white is
only ~2.8:1.
- Identity: pass color per peer to use your own product palette or a server-
assigned colour; hash a stable user id instead of a display name if names
change; add an avatar or a "following" badge inside the flag span.
- Reach: onPresenceChange feeds a visible facepile from the same source of
truth as the spoken roster. The roster sentence is four lines of string
building — translate it there.
- Coordinate space: normalise in the space your peers SHARE. Over a scrolling
document that is the content box, not the viewport; over a zoomable canvas,
divide by the world size before sending and the overlay needs no camera at
all.Concepts
- Delay budget — smoothing is not a free prettifier, it is latency you choose to spend. Transport staleness is on average half an update interval (125 ms at 4 Hz) and the exponential smoother adds roughly its own time constant on top, so the defaults draw a peer about 215 ms in the past.
followMs ≈ 0.4 × intervalcovers 94% of each gap just as the next sample lands: smaller reads as stutter, larger reads as drunk. - Change token, not a timestamp — a click is relayed as a
clickIdthat merely differs from the last one, so no clock has to be shared between machines and a replayed state cannot re-fire the past. The first value a peer arrives with is stored silently, which is what stops every join and every remount from flashing a ripple. - Idle on the animation clock — stillness is measured with the rAF timestamp the loop is already handed, never with a clock read during render, so the component is deterministic on the server and in tests. Only the threshold crossing reaches React state; the fade itself is DOM writes, so a busy room costs zero renders.
- Park and pause — the loop stops itself once every cursor has settled and finished fading, and an IntersectionObserver plus
visibilitychangestop it dead off-screen or in a hidden tab. Because time only advances while it runs, no one silently rots into idle while you were on another tab; the first frame back places every cursor instead of gliding it across a gap that was never travelled. - Sample and hold —
pointermovedoes nothing but write two numbers to a ref; the loop converts and emits at mostbroadcastHztimes a second and only when the position really moved. Nothing queues, so a burst of moves costs one message, and the newest position always wins. - aria-hidden picture, spoken roster — the painted layer repeats nothing a screen reader can use, so it is hidden outright; the accessible surface is one polite
role="status"sentence naming who is present and who has gone quiet, which is also exactly whatonPresenceChangehands your own facepile.
Order Tracking
A shipment tracking card — a four-stage rail, an ETA derived from an injected instant, exceptions that interrupt without colour alone, per-parcel tabs and a copyable tracking number.
JSON Diff
A structural diff of two JSON values as one tree — added, removed and changed keys marked by sign and wording as well as colour, changed leaves showing before → after inline, unchanged subtrees folded behind counts, and arrays paired by index or by an identity field.