Skip to main content
A collaborative cursor component that displays the real-time positions of all users in a room. Perfect for building collaborative editors, whiteboards, or any multi-user application.

Installation

npx shadcn@latest add https://convex-ui.vercel.app/r/realtime-cursor-react
This installs:
  • RealtimeCursors container component
  • Individual Cursor component with smooth animations
  • Custom hook for cursor tracking
  • Convex presence backend
  • Convex client setup

What’s Included

Smooth Animations

Cursor movements animated with Framer Motion

Color Coding

Each user gets a unique color for identification

Presence Tracking

Automatic join/leave detection and cleanup

Position Throttling

Optimized updates to prevent network overload

Setup

1
Deploy Convex Backend
2
npx convex dev
3
Configure Environment
4
VITE_CONVEX_URL=https://your-deployment.convex.cloud
5
Add Provider
6
Ensure ConvexClientProvider wraps your app (automatically included).

Component

RealtimeCursors

Container component that tracks and displays cursors.
import { RealtimeCursors } from "@/components/realtime-cursors";

function CollaborativeCanvas() {
  return (
    <RealtimeCursors
      roomName="canvas-1"
      username="Alice"
      userColor="#3b82f6"
      className="h-screen w-full"
    >
      {/* Your collaborative content */}
      <div className="p-8">
        <h1>Shared Canvas</h1>
        <p>Move your mouse to see cursors</p>
      </div>
    </RealtimeCursors>
  );
}

Props

roomName
string
required
Unique identifier for the room. Users in the same room see each other’s cursors.
username
string
required
Display name shown next to the cursor.
userColor
string
default:"#3b82f6"
Hex color for the user’s cursor (e.g., "#ff0000").
children
ReactNode
Content to render inside the cursor container.
className
string
Additional CSS classes for the container.

Usage Examples

import { RealtimeCursors } from '@/components/realtime-cursors';

function App() {
  return (
    <RealtimeCursors
      roomName="room-1"
      username="Alice"
      userColor="#3b82f6"
      className="h-screen"
    >
      <div className="p-8">
        <h1>Collaborative Workspace</h1>
      </div>
    </RealtimeCursors>
  );
}

Custom Hook

useRealtimeCursors

Use the cursor tracking logic independently:
import { useRealtimeCursors } from '@/hooks/use-realtime-cursors';
import { useEffect, useRef } from 'react';

function CustomCursorImplementation() {
  const containerRef = useRef(null);
  const { cursors, createMouseMoveHandler, setContainer, isConnected } = 
    useRealtimeCursors({
      roomName: 'my-room',
      username: 'Alice',
      userColor: '#3b82f6',
      throttleMs: 100,
    });
  
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    
    setContainer(container);
    const handleMove = createMouseMoveHandler(container);
    
    container.addEventListener('mousemove', handleMove);
    return () => {
      container.removeEventListener('mousemove', handleMove);
      setContainer(null);
    };
  }, [createMouseMoveHandler, setContainer]);
  
  return (
    <div ref={containerRef}>
      {cursors.map(cursor => (
        <div 
          key={cursor.id}
          style={{
            position: 'absolute',
            left: cursor.position.x,
            top: cursor.position.y,
            color: cursor.color,
          }}
        >
          {cursor.name}
        </div>
      ))}
    </div>
  );
}
Arguments:
roomName
string
required
Room identifier
username
string
required
Current user’s display name
userColor
string
default:"#3b82f6"
Hex color for cursor
throttleMs
number
default:100
Minimum milliseconds between position updates
Returns:
{
  cursors: Cursor[],
  updateCursor: (position: { x: number, y: number }) => void,
  createMouseMoveHandler: (container: HTMLElement) => (e: MouseEvent) => void,
  setContainer: (container: HTMLElement | null) => void,
  isConnected: boolean,
  sessionId: string
}

Cursor Type

interface Cursor {
  id: string;          // Unique presence ID
  name: string;        // User's display name
  color: string;       // Hex color code
  position: {
    x: number;         // X coordinate relative to container
    y: number;         // Y coordinate relative to container
  };
}

Backend Functions

list

Query all active users in a room.
api.presence.list({ roomId: "room-1" })
Returns:
Array<{
  _id: Id<"presence">,
  _creationTime: number,
  roomId: string,
  sessionId: string,
  lastSeen: number,
  data?: {
    position?: { x: number, y: number },
    name?: string,
    color?: string
  }
}>

update

Update cursor position.
api.presence.update({
  roomId: "room-1",
  sessionId: "session-123",
  data: {
    position: { x: 100, y: 200 },
    name: "Alice",
    color: "#3b82f6"
  }
})

leave

Remove user from room (called on unmount).
api.presence.leave({
  roomId: "room-1",
  sessionId: "session-123"
})

Features

Position Throttling

Cursor updates are throttled to prevent overwhelming the network:
  • Default: 100ms between updates (10 updates/second)
  • Configurable via throttleMs prop
  • Smooth for users, efficient for servers

Automatic Cleanup

Users are automatically removed when:
  • Component unmounts
  • Browser tab closes
  • Connection is lost

Session Management

Each component instance gets a unique session ID:
  • Generated on mount: session-{timestamp}-{random}
  • NOT stored in localStorage (prevents iframe conflicts)
  • Filters out own cursor automatically

Relative Positioning

Cursor positions are relative to the container:
  • Works with scrolling containers
  • Positions calculated from getBoundingClientRect()
  • Accurate even with nested elements

Animations

Cursors use Framer Motion for smooth animations:
components/cursor.tsx
import { motion } from "framer-motion";

export function Cursor({ name, color, position }) {
  return (
    <motion.div
      className="absolute pointer-events-none z-50"
      initial={{ x: position.x, y: position.y }}
      animate={{ x: position.x, y: position.y }}
      transition={{ 
        type: "spring", 
        damping: 30, 
        stiffness: 300 
      }}
    >
      {/* Cursor SVG */}
      <svg width="24" height="24" fill={color}>
        <path d="M5 3l14 9-5 1-3 5-6-15z" />
      </svg>
      <span className="ml-2 text-xs font-semibold" style={{ color }}>
        {name}
      </span>
    </motion.div>
  );
}

Customization

Custom Cursor Design

Modify components/cursor.tsx:
export function Cursor({ name, color, position }) {
  return (
    <motion.div
      style={{ 
        left: position.x, 
        top: position.y,
        position: 'absolute',
      }}
    >
      {/* Your custom cursor design */}
      <div 
        className="w-4 h-4 rounded-full"
        style={{ backgroundColor: color }}
      />
      <div className="bg-black text-white px-2 py-1 rounded text-xs">
        {name}
      </div>
    </motion.div>
  );
}

Custom Update Frequency

<RealtimeCursors
  roomName="fast-room"
  username="Alice"
  userColor="#3b82f6"
  // Custom throttle - updates every 50ms (20 updates/sec)
>
  {/* Pass via hook if using custom implementation */}
  {children}
</RealtimeCursors>
To customize throttling, modify the hook call in the component.

Schema

The presence table schema:
convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  presence: defineTable({
    roomId: v.string(),
    sessionId: v.string(),
    lastSeen: v.number(),
    data: v.optional(v.any()),
  })
    .index("by_room", ["roomId"])
    .index("by_session", ["sessionId", "roomId"]),
});

Performance

Throttled Updates

Position updates limited to 10/second by default

Indexed Queries

Fast room lookups using Convex indexes

Automatic Cleanup

Old presence records removed on unmount

Efficient Rendering

Only re-renders when cursor positions change

Troubleshooting

Verify the container has position: relative (or className includes positioning). The cursors use position: absolute and need a positioned ancestor.
Ensure you’re tracking mouse position relative to the container. The hook’s createMouseMoveHandler calculates this automatically using getBoundingClientRect().
This happens if sessionId is shared across tabs. The component generates unique session IDs per instance. Don’t use localStorage for session management.
Adjust throttleMs to a lower value for more frequent updates, or higher to reduce network traffic. Default 100ms is optimal for most use cases.

Next Steps

Avatar Stack

Show who’s in the room with avatars

Realtime Chat

Add chat to your collaborative app

Password Auth

Authenticate users in collaborative spaces

Build docs developers (and LLMs) love