Anatomy of Live Cursors
Why multiplayer cursors chase a spring instead of teleporting to every network update, and how each one enters and leaves without a manual timer.
↓Scroll to step through it
One motion.div, two shapes
A cursor position arriving over a websocket is noisy: batched, occasionally
late, never perfectly smooth. Write it straight to style.left and the
cursor teleports every time a message lands. In LiveCursors, every
incoming coordinate is a target for a spring, and the spring is what gets
painted.
The pointer and the name flag are siblings inside one animated motion.div.
Only the outer element's x/y ever moves, so the pointer and flag stay in
a fixed relationship to each other. rounded-tl-sm on the flag is the same
idea as the comment pin's rounded-bl-sm: leaving one corner square gives
it a tail, here pointing back up at the cursor tip it's attached to.
Raw updates vs a spring
useSpring holds a motion value that chases whatever you .set() it to
instead of jumping there. Every new { x, y } prop retargets the same
spring instead of restarting an animation, so a burst of fast updates (a
teammate scribbling quickly) doesn't queue up a pile of separate tweens.
The spring keeps re-aiming at the newest target. stiffness: 700 is high,
tuned to feel almost immediate, but the lag never reaches zero. The small
catch-up motion reads as a person moving rather than a value snapping
between fixed points.
Reduced motion keeps the stiffness and spring as they are and swaps to
.jump(), the same instant repositioning a raw update would have done.
Someone who turned off motion wants no chase at all, and a faster chase
would still move. Keeping the two paths as an explicit if rather than
tuning one spring down to "fast enough" guarantees that reduced motion
holds completely still.
Enter and exit are the array, not a toggle
There's no visible prop and no manual show/hide state anywhere in this
component. AnimatePresence wraps a .map() over cursors, keyed by
cursor.id. When the array a caller passes in gains or loses an entry
(a peer joins, a peer's connection drops), React's own reconciliation
triggers the initial or exit transition. The component ignores why
the array changed and reacts only to the diff.
SimulatedCursors doesn't animate anything itself. It runs a plain random
walk in a requestAnimationFrame loop (each peer eases 2% of the way
toward a random target every frame, picking a new target once it arrives)
and hands the result to LiveCursors as ordinary cursors prop updates.
The spring above still does all the smoothing; the simulation only
supplies noisy target coordinates, the same way a network feed would.
The rAF loop checks reduce once and returns immediately if motion is
disabled. No peers move, so nothing gets interpolated and no frames are
spent. The loop itself is cleaned up with
cancelAnimationFrame on unmount, and each CursorFlag's springs are
owned by Framer's own lifecycle: no cursor keeps a timer running after
AnimatePresence finishes its exit and removes it from the tree.
The result
Each update retargets a spring instead of painting a value straight to
style. Reduced motion swaps to instant positioning rather than a faster
tween. Enter and exit follow the shape of the cursors array, with no
cursor-specific show/hide logic.
- Pointer
- SVG path, presence-color fill, white stroke
- Name flag
- mt-3 -ml-1, rounded-tl-sm, so the tail points at the pointer
<motion.div style={{ x, y }} className="absolute left-0 top-0 z-popover flex items-start"><svg viewBox="0 0 24 24" width="20" height="20" style={{ color }}> <path d="M5 3l5.5 16 2.2-6.8L19.5 10z" fill="currentColor" stroke="white" strokeWidth={1.5} /></svg><span className="mt-3 -ml-1 rounded-[10px] rounded-tl-sm ...">{cursor.message ?? cursor.name}</span></motion.div>Anatomy of Live Cursors
Why multiplayer cursors chase a spring instead of teleporting to every network update, and how each one enters and leaves without a manual timer.
- Pointer
- SVG path, presence-color fill, white stroke
- Name flag
- mt-3 -ml-1, rounded-tl-sm, so the tail points at the pointer
<motion.div style={{ x, y }} className="absolute left-0 top-0 z-popover flex items-start"><svg viewBox="0 0 24 24" width="20" height="20" style={{ color }}> <path d="M5 3l5.5 16 2.2-6.8L19.5 10z" fill="currentColor" stroke="white" strokeWidth={1.5} /></svg><span className="mt-3 -ml-1 rounded-[10px] rounded-tl-sm ...">{cursor.message ?? cursor.name}</span></motion.div>One motion.div, two shapes
A cursor position arriving over a websocket is noisy: batched, occasionally
late, never perfectly smooth. Write it straight to style.left and the
cursor teleports every time a message lands. In LiveCursors, every
incoming coordinate is a target for a spring, and the spring is what gets
painted.
The pointer and the name flag are siblings inside one animated motion.div.
Only the outer element's x/y ever moves, so the pointer and flag stay in
a fixed relationship to each other. rounded-tl-sm on the flag is the same
idea as the comment pin's rounded-bl-sm: leaving one corner square gives
it a tail, here pointing back up at the cursor tip it's attached to.
raw · x.jump(cursor.x)
spring · useSpring(700, 40, 0.6)
- Raw
- position snaps, no interpolation between updates
- Spring
- chases the target, arrives with a touch of overshoot
const config = { stiffness: 700, damping: 40, mass: 0.6 };const x = useSpring(cursor.x, config);const y = useSpring(cursor.y, config);React.useEffect(() => {if (reduce) { x.jump(cursor.x); y.jump(cursor.y);} else { x.set(cursor.x); y.set(cursor.y);}}, [cursor.x, cursor.y, x, y, reduce]);Raw updates vs a spring
useSpring holds a motion value that chases whatever you .set() it to
instead of jumping there. Every new { x, y } prop retargets the same
spring instead of restarting an animation, so a burst of fast updates (a
teammate scribbling quickly) doesn't queue up a pile of separate tweens.
The spring keeps re-aiming at the newest target. stiffness: 700 is high,
tuned to feel almost immediate, but the lag never reaches zero. The small
catch-up motion reads as a person moving rather than a value snapping
between fixed points.
Reduced motion keeps the stiffness and spring as they are and swaps to
.jump(), the same instant repositioning a raw update would have done.
Someone who turned off motion wants no chase at all, and a faster chase
would still move. Keeping the two paths as an explicit if rather than
tuning one spring down to "fast enough" guarantees that reduced motion
holds completely still.
- Enter
- opacity 0 → 1, scale 0.5 → 1, 150ms
- Exit
- the mirror transition, then unmounts without a manual timer
<AnimatePresence>{cursors.map((cursor) => ( <CursorFlag key={cursor.id} cursor={cursor} hideName={hideNames} />))}</AnimatePresence>initial={{ opacity: 0, scale: 0.5 }}animate={{ opacity: 1, scale: 1 }}exit={{ opacity: 0, scale: 0.5 }}transition={{ duration: 0.15 }}const tick = () => {for (const peer of peers) { peer.x += (peer.tx - peer.x) * 0.02; peer.y += (peer.ty - peer.y) * 0.02; if (Math.abs(peer.tx - peer.x) < 0.02) peer.tx = 0.1 + Math.random() * 0.8;}setCursors(peers.map((p) => ({ id: p.id, name: p.name, x: p.x * w, y: p.y * h })));raf = requestAnimationFrame(tick);};Enter and exit are the array, not a toggle
There's no visible prop and no manual show/hide state anywhere in this
component. AnimatePresence wraps a .map() over cursors, keyed by
cursor.id. When the array a caller passes in gains or loses an entry
(a peer joins, a peer's connection drops), React's own reconciliation
triggers the initial or exit transition. The component ignores why
the array changed and reacts only to the diff.
SimulatedCursors doesn't animate anything itself. It runs a plain random
walk in a requestAnimationFrame loop (each peer eases 2% of the way
toward a random target every frame, picking a new target once it arrives)
and hands the result to LiveCursors as ordinary cursors prop updates.
The spring above still does all the smoothing; the simulation only
supplies noisy target coordinates, the same way a network feed would.
The rAF loop checks reduce once and returns immediately if motion is
disabled. No peers move, so nothing gets interpolated and no frames are
spent. The loop itself is cleaned up with
cancelAnimationFrame on unmount, and each CursorFlag's springs are
owned by Framer's own lifecycle: no cursor keeps a timer running after
AnimatePresence finishes its exit and removes it from the tree.
The result
Each update retargets a spring instead of painting a value straight to
style. Reduced motion swaps to instant positioning rather than a faster
tween. Enter and exit follow the shape of the cursors array, with no
cursor-specific show/hide logic.
Motion Score
opacityFade / cross-fade