Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

/** Trail dots one peer can own. `trail` is clamped to it and the ring buffer sized by it. */
const TRAIL_MAX = 6
/** One trail sample per this much animation time, so 60Hz and 144Hz draw the same tail. */
const TRAIL_SAMPLE_MS = 45
/** Ripple nodes are allocated once and shared by every peer. */
const RIPPLE_SLOTS = 8
/** A longer frame is a stall (long task, tab switch); the step is clamped to it. */
const MAX_FRAME_MS = 120

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/live-cursors.json

Prompt

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 × interval covers 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 clickId that 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 visibilitychange stop 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 holdpointermove does nothing but write two numbers to a ref; the loop converts and emits at most broadcastHz times 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 what onPresenceChange hands your own facepile.

On This Page