Skip to main content
A real-time avatar stack component that shows all users currently present in a room. Perfect for collaborative applications, showing online presence, or displaying active participants.

Installation

npx shadcn@latest add https://convex-ui.vercel.app/r/realtime-avatar-stack-react
This installs:
  • RealtimeAvatarStack component
  • AvatarStack display component
  • Custom presence hook
  • Convex presence backend
  • Convex client setup

What’s Included

Real-time Presence

Updates instantly as users join and leave

Overlapping Avatars

Beautiful stacked layout with customizable max display

User Count

Shows total number of online users

Tooltips

Hover to see user names

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

RealtimeAvatarStack

Displays avatars of all users in a room.
import { RealtimeAvatarStack } from "@/components/realtime-avatar-stack";

function DocumentHeader() {
  return (
    <div className="flex items-center justify-between p-4">
      <h1>Shared Document</h1>
      <RealtimeAvatarStack
        roomName="document-1"
        user={{
          name: "Alice",
          image: "https://github.com/alice.png",
          color: "#3b82f6"
        }}
        maxVisible={5}
        size="md"
      />
    </div>
  );
}

Props

roomName
string
required
Unique identifier for the room. Users in the same room appear together.
user
object
required
Current user information:
  • name: Display name (required)
  • image: Avatar image URL (optional)
  • color: Hex color for fallback avatar (optional)
maxVisible
number
default:5
Maximum number of avatars to show before showing “+N” indicator.
size
'sm' | 'md' | 'lg'
default:"md"
Avatar size:
  • sm: 32px (h-8 w-8)
  • md: 40px (h-10 w-10)
  • lg: 48px (h-12 w-12)
className
string
Additional CSS classes for the container.

Usage Examples

import { RealtimeAvatarStack } from '@/components/realtime-avatar-stack';

function App() {
  return (
    <RealtimeAvatarStack
      roomName="room-1"
      user={{
        name: "Alice",
        image: "https://github.com/alice.png"
      }}
    />
  );
}

Custom Hook

useRealtimePresenceRoom

Manage room presence independently:
import { useRealtimePresenceRoom } from '@/hooks/use-realtime-presence-room';

function CustomPresence() {
  const { users, isConnected, userCount } = useRealtimePresenceRoom({
    roomName: 'my-room',
    user: {
      name: 'Alice',
      image: 'https://github.com/alice.png',
      color: '#3b82f6',
    },
    heartbeatMs: 10000,
  });
  
  return (
    <div>
      <div>Status: {isConnected ? 'Connected' : 'Connecting...'}</div>
      <div>Users online: {userCount}</div>
      <ul>
        {users.map(user => (
          <li key={user.id}>
            {user.name}
            {user.image && <img src={user.image} alt={user.name} />}
          </li>
        ))}
      </ul>
    </div>
  );
}
Arguments:
roomName
string
required
Room identifier
user
object
required
Current user data (name, image, color)
heartbeatMs
number
default:10000
Milliseconds between heartbeat updates (keeps presence alive)
Returns:
{
  users: PresenceUser[],
  isConnected: boolean,
  userCount: number
}

PresenceUser Type

interface PresenceUser {
  id: string;          // Unique presence ID
  name: string;        // Display name
  image?: string;      // Avatar image URL
  color?: string;      // Hex color code
}

Backend Functions

list

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

update

Update user presence (heartbeat).
api.presence.update({
  roomId: "room-1",
  sessionId: "session-123",
  data: {
    name: "Alice",
    userImage: "https://github.com/alice.png",
    color: "#3b82f6"
  }
})

leave

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

Features

Heartbeat System

Keeps users marked as “online”:
  • Automatic heartbeat every 10 seconds (configurable)
  • Updates presence timestamp
  • Backend can clean up stale entries

Automatic Cleanup

Users removed when:
  • Component unmounts
  • Browser tab closes
  • Heartbeat stops

Session Management

Unique session per component instance:
  • Generated: session-{timestamp}-{random}
  • NOT in localStorage (avoids iframe conflicts)
  • Allows multiple tabs per user

Avatar Overflow

Shows ”+ N more” when exceeding maxVisible:
// Shows: [Avatar] [Avatar] [Avatar] +5
<RealtimeAvatarStack maxVisible={3} />

AvatarStack Component

The underlying display component (also usable standalone):
import { AvatarStack } from '@/components/avatar-stack';

const users = [
  { id: '1', name: 'Alice', image: 'https://...' },
  { id: '2', name: 'Bob', image: 'https://...' },
  { id: '3', name: 'Charlie' },
];

function StaticStack() {
  return (
    <AvatarStack 
      users={users} 
      maxVisible={3} 
      size="md" 
    />
  );
}

Customization

Custom Overlap

Modify components/avatar-stack.tsx:
<div className="flex items-center -space-x-2">
  {/* Change -space-x-2 to adjust overlap */}
  {visibleUsers.map(user => (
    <Avatar key={user.id}>
      <AvatarImage src={user.image} />
      <AvatarFallback>{getInitials(user.name)}</AvatarFallback>
    </Avatar>
  ))}
</div>

Custom Tooltips

import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/ui/tooltip';

<Tooltip>
  <TooltipTrigger>
    <Avatar>{/* ... */}</Avatar>
  </TooltipTrigger>
  <TooltipContent>
    <p>{user.name}</p>
    <p className="text-xs text-muted-foreground">Online now</p>
  </TooltipContent>
</Tooltip>

Custom Overflow Badge

{remaining > 0 && (
  <div className="flex items-center justify-center h-10 w-10 rounded-full bg-primary text-primary-foreground text-sm font-semibold">
    +{remaining}
  </div>
)}

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"]),
});

Stale Entry Cleanup

Optionally add a cron job to remove stale presence entries:
convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";

const crons = cronJobs();

crons.interval(
  "clean stale presence",
  { minutes: 5 },
  internal.presence.cleanStale
);

export default crons;
convex/presence.ts
export const cleanStale = internalMutation({
  handler: async (ctx) => {
    const staleThreshold = Date.now() - 60000; // 1 minute
    const allPresence = await ctx.db.query("presence").collect();
    
    for (const presence of allPresence) {
      if (presence.lastSeen < staleThreshold) {
        await ctx.db.delete(presence._id);
      }
    }
  },
});

Performance

Indexed Queries

Fast room lookups using Convex indexes

Heartbeat Optimization

Infrequent updates (every 10s) reduce load

Reactive Updates

Only re-renders when users join/leave

Efficient Rendering

Avatar stack limits visible elements

Troubleshooting

Verify npx convex dev is running and environment variables are set. Check that the presence heartbeat is firing (should update every 10 seconds).
Ensure all users are in the same roomName. Check browser console for errors in the presence update mutation.
This can happen if session IDs are shared. The hook generates unique IDs automatically - don’t override with localStorage.
Implement the cleanup cron job to remove users who haven’t sent a heartbeat in >1 minute.

Next Steps

Realtime Cursors

Add collaborative cursor tracking

Realtime Chat

Build a chat for room participants

Current User Avatar

Display the authenticated user’s avatar

Build docs developers (and LLMs) love