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
VITE_CONVEX_URL = https://your-deployment.convex.cloud
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
Unique identifier for the room. Users in the same room appear together.
Current user information:
name: Display name (required)
image: Avatar image URL (optional)
color: Hex color for fallback avatar (optional)
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)
Additional CSS classes for the container.
Usage Examples
Basic Usage
With Authentication
Custom Colors
Small Avatar List
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:
Current user data (name, image, color)
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 >
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:
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:
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 ;
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 );
}
}
},
});
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