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.
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.