Skip to content

Live Cursors

Show who is on a shared surface and where they point: cursors, names, chat bubbles and selections, fed by your own realtime channel.

import { LiveCursorPosition, LiveCursorRect, LiveCursors, LiveCursorsInputOptions, LiveCursorUser, PresenceAvatars } from '@youj/react'

Installation & usage

npm i @youj/react

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Use live cursors on anything several people work on at once: a design canvas, a whiteboard, a shared document, a dashboard being reviewed together. Youj has no transport of its own: you send the local pointer over whatever you already use (WebSockets, WebRTC, Liveblocks, Supabase Realtime, a CRDT's awareness) and pass everyone else back in.

import { LiveCursors, PresenceAvatars, useLiveCursorsInput, type LiveCursorUser } from '@youj/react'

const surface = useRef<HTMLDivElement>(null)
const [others, setOthers] = useState<LiveCursorUser[]>([])
const [following, setFollowing] = useState<string | null>(null)

// Send your pointer: { x, y } in fractions of the surface, or null when it leaves.
useLiveCursorsInput(surface, (position) => channel.send({ id: me.id, name: me.name, ...position }))
// Receive everyone else's into `others` (leave yourself out).
useEffect(() => channel.onPresence(setOthers), [])

<PresenceAvatars users={others} following={following} onFollowingChange={setFollowing} />
<LiveCursors ref={surface} users={others} following={following}>
  <Canvas />
</LiveCursors>

LiveCursors wraps the surface (it renders a relative box around its children) and draws a cursor for each person in users with an x and y. Positions are fractions of the surface, 0 to 1 from the top left, so a person on a wide screen and one on a phone point at the same spot. Leave x and y out while someone's pointer is elsewhere and their cursor goes; remove them from users when they leave.

Each person has a color, by default one of the theme's five chart colors picked from their id, so every screen gives them the same one. Five colors mean two people can share one; pass color (a slot your server hands out, say) where they must differ. The name tag's text turns black or white for contrast. A message shows as a chat bubble under the name, and selection draws their selected areas as tinted rectangles.

Updates can be sparse: each cursor springs to its newest position, all of them on one animation loop that stops when they rest or the surface scrolls out of view. A cursor that hasn't moved for idleAfter milliseconds (10 seconds by default; 0 keeps them) fades out until it moves or speaks again.

useLiveCursorsInput(ref, onMove, { throttle }) reports the pointer at most every throttle milliseconds (50 by default), always ending on the last move, and null when it leaves the surface.

PresenceAvatars shows who is here, with each person's color as a ring and their status ("Viewing" or "Editing") in a tooltip. Give it following and onFollowingChange and each avatar becomes a button that follows that person: pass the same following to LiveCursors, which scrolls their cursor into view as they move. Without them, the avatars only show who's here. Past max (5) the rest are counted.

Reference

Propsusers, following, idleAfter… (17)

Props

LiveCursors

LiveCursors props
PropType
usersRequiredreadonly LiveCursorUser[]

Everyone else on the surface. Leave the local person out.

–
followingstring | null

The id of a person to follow: their cursor is scrolled into view as it moves. Pair it with PresenceAvatars' following.

–
idleAfternumber

Milliseconds without a move (or a new message) before a cursor fades out. 0 keeps every cursor.

default DEFAULT_IDLE

DEFAULT_IDLE
announceboolean

Announce joins and leaves in a polite live region.

default true

true
localestring

Formats the announced lists of names. Default: the YoujProvider locale.

–
Other props pass through: extends ComponentProps<'div'>.

PresenceAvatars

PresenceAvatars props
PropType
usersRequiredreadonly LiveCursorUser[]

Everyone here, in the order to show them.

–
maxnumber

Avatars shown before "+N".

default 5

5
size'sm' | 'default' | 'lg'

default 'default'

'default'
followingstring | null

The id of the person being followed, or null. Controlled.

–
defaultFollowingstring | null

The person followed at first.

default null

null
onFollowingChange(id: string | null) => void

Called with an id when an avatar is pressed, and null when it's pressed again. Leave it out, and following too, for avatars that only show who's here.

–
labelstring

The group's name. Default: the labels' "People here".

–
Other props pass through: extends ComponentProps<'div'>.

LiveCursorUser

FieldType
idstringStable across updates: it keys the cursor and picks the color.
namestringThe name tag.
x, ynumberWhere their pointer is, as fractions of the surface. Leave both out while it's elsewhere.
colorstringAny CSS color. Default: a chart color chosen by id.
messagestringA chat bubble under the name.
selectionLiveCursorRect[]Selected areas, { x, y, width, height } in fractions.
status'viewing' | 'editing'Shown in PresenceAvatars' tooltip.
srcstringAn avatar image.
StylingParts and data attributes

Styling

Parts: live-cursors (with data-yj-following while a placed person is followed), live-cursors-overlay, live-cursor (with data-yj-idle once faded and data-yj-following), live-cursor-name, live-cursor-message, live-cursor-selection, live-cursors-follow-anchor, presence-avatars, presence-avatar (with data-status: viewing or editing, and aria-pressed while followed) and presence-avatars-more. Each cursor, selection and avatar sets --ui--live-cursor to the person's color. The overlay is left to right whatever the page's direction: a position is the same spot on every screen.

MotionGlide, enter, leave, idle · reduced motion

Motion

Motion tokens
MomentTokens
Cursor moveseach cursor springs to its newest position, all on one animation loop that stops when they rest or the surface is off screen
spring.snappy

0.2s, bounce 0

0.2s, bounce 0
Someone arrives or leavesthe cursor scales up from its tip, or shrinks away
duration.fastease.enter

140ms · cubic-bezier(0.16, 1, 0.3, 1)

140mscubic-bezier(0.16, 1, 0.3, 1)
Chat bubblepops in under the name and out when the message clears
duration.fastease.enter

140ms · cubic-bezier(0.16, 1, 0.3, 1)

140mscubic-bezier(0.16, 1, 0.3, 1)
Idlea cursor that stops moving fades out after idleAfter
duration.slowease.standard

340ms · cubic-bezier(0.2, 0, 0, 1)

340mscubic-bezier(0.2, 0, 0, 1)

Reduced motion

Cursors jump to each update, and appear, go idle and leave at once.

Reduced motion

Each cursor jumps to its newest position; cursors, bubbles and selections appear and go without fades or scaling, and following scrolls without smoothing.

AccessibilityHidden cursors, announcements, avatars

Accessibility

  • The cursors, bubbles and selections are decorative and hidden from screen readers; the surface's own content stays as it is.
  • A polite live region announces who joined and who left ("Joan and Kenji joined. Grace left"), not who was already there. Turn it off with announce={false} where the app announces presence itself.
  • PresenceAvatars is a group named "People here" (or label). With following, each avatar is a toggle button named "Follow Ada", pressed while followed, and starting to follow is announced ("Following Ada"); without it, each is an image named with the person and their status ("Ada, Editing").
  • Its words come from the labels (liveCursorsJoined, liveCursorsLeft, liveCursorsPeople, liveCursorsViewing, liveCursorsEditing, liveCursorsFollow, liveCursorsFollowing and liveCursorsMore), translated by the language packs; lists of names are formatted by Intl in the locale.