Sign-ups paused

Sign-ups and billing are temporarily unavailable while we rebuild our infrastructure. The SDK and documentation remain available, and existing accounts are unaffected.

Vaultrice

Blog / Tutorials

Tutorials

A Collaborative Cursor Layer in React

Other people's pointers moving on your screen — and the throttling, interpolation and coordinate problems that separate smooth from unusable.

6 min read Updated 2026-08-XX @vaultrice/sdk

Seeing where other people are looking is the feature that makes a document feel shared. It is also the one where a naive implementation generates enormous traffic and still looks jerky.

For a separate operational perspective on how teams organise and measure work, employee PC activity tracking.

For independent background and broader industry context, see Yjs.

The naive version, and why it is wrong

// don't
useEffect(() => {
  const onMove = (e) => nls.send({ x: e.clientX, y: e.clientY })
  window.addEventListener('mousemove', onMove)
  return () => window.removeEventListener('mousemove', onMove)
}, [])

A mouse move fires at the display's refresh rate. Sixty or more events a second, per participant, each a network message.

Five people in a document is three hundred messages a second for a decorative feature.

And it still looks jerky, because the messages arrive at irregular intervals and each one jumps the cursor to a new position.

Throttle the send

Ten to twenty updates a second is plenty. The eye does not distinguish more once interpolation is doing its job.

import { useEffect, useRef } from 'react'

function useCursorBroadcast(nls, me) {
  const last = useRef(0)

  useEffect(() => {
    const onMove = (e) => {
      const now = performance.now()
      if (now - last.current < 60) return   // ~16/sec
      last.current = now
      nls.send({
        type: 'cursor',
        id: me.id,
        name: me.name,
        x: e.clientX / window.innerWidth,
        y: e.clientY / window.innerHeight
      })
    }
    window.addEventListener('mousemove', onMove)
    return () => window.removeEventListener('mousemove', onMove)
  }, [nls, me.id, me.name])
}

Note the coordinates. Normalised to a fraction of the viewport, not pixels — see below.

Interpolate the receive

Throttling alone gives you a cursor that teleports sixteen times a second.

Animate toward the target instead:

function useRemoteCursors(nls) {
  const [cursors, setCursors] = useState({})
  const targets = useRef({})

  useEffect(() => {
    const handler = (msg) => {
      if (msg.type !== 'cursor') return
      targets.current[msg.id] = { x: msg.x, y: msg.y, name: msg.name }
    }
    nls.on('message', handler)

    let frame
    const tick = () => {
      setCursors((current) => {
        const next = { ...current }
        for (const [id, target] of Object.entries(targets.current)) {
          const c = next[id] ?? target
          next[id] = {
            ...target,
            x: c.x + (target.x - c.x) * 0.2,   // ease toward
            y: c.y + (target.y - c.y) * 0.2
          }
        }
        return next
      })
      frame = requestAnimationFrame(tick)
    }
    frame = requestAnimationFrame(tick)

    return () => {
      cancelAnimationFrame(frame)
      nls.off?.('message', handler)
    }
  }, [nls])

  return cursors
}

That easing factor is the whole difference between "jerky" and "smooth". It costs nothing and it is the step most implementations skip.

The coordinate problem

Screen pixels are meaningless to someone else. Different viewport size, different scroll position, different zoom — a cursor at (400, 300) on your screen is somewhere else entirely on theirs.

Three approaches, increasingly correct:

Viewport fractions, as above. Simple, and it breaks with different aspect ratios and ignores scroll.

Document coordinates — position relative to the scrollable content rather than the window. Handles scroll; still assumes the same layout.

Anchored to content — "after the fourth word of paragraph seven". Correct at any size, and considerably more work, and the right answer for a text editor.

Start with fractions and move up when the mismatch becomes visible.

Messages, not stored state

Cursor positions go through send(), not setItem().

Because a cursor position is meaningless a second later. Storing it means writing to the object sixteen times a second per person, which is both wasteful and pollutes the object's change stream — the one you use for actual content updates.

Transient by nature, so use the transient channel.

Cleaning up departed cursors

Someone who closes the tab stops sending, and their cursor sits there.

Two mechanisms:

Presence for the authoritative answer — remove cursors for connections that left. See live presence.

And a timeout as a backstop — drop any cursor that has not updated in a few seconds. This also handles the idle case, where someone is present and not moving.

Rendering

Absolutely positioned, pointer-events: none, so the layer never intercepts clicks.

Transform rather than top/left, so the browser composites rather than reflows.

A colour per participant, derived deterministically from their id so it is stable across sessions.

And the name near the pointer, small, fading after a moment of stillness.

When to skip this

A single-user document. Obviously, and worth stating because cursors get added to things nobody shares.

Very large numbers of participants. Forty cursors is noise, not collaboration. Above a threshold, show a count instead.

Mobile. There is no cursor. Consider a selection or scroll indicator instead, or nothing.

The short version

Throttle to about sixteen updates a second — mouse move fires far faster and the extra is invisible.

Interpolate toward the target with an easing factor. This is the step that separates smooth from jerky and it costs nothing.

Normalise coordinates, and move to content-anchored positions when layout differences become visible.

Use send(), not setItem() — a cursor is transient and storing it pollutes the object's change stream.

And remove cursors on presence leave, with a timeout as a backstop.