A live-collaboration presence layer: an avatar stack that pops on join and leave, plus colored labeled cursors that glide to each user.
npx shadcn@latest add @paragon/presence-avatars"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface PresenceUser {
id: string;
name: string;
color: string;
/** Cursor position as fractions of the canvas, 0–1. Omit to hide the cursor. */
cursor?: { x: number; y: number };
}
export interface PresenceAvatarsProps extends React.ComponentProps<"div"> {
users: PresenceUser[];
/** Max avatars before collapsing into a "+N" chip. */
max?: number;
/** Renders join/leave and cursor moves without animation. */
static?: boolean;
}
function initials(name: string) {
return name
.split(" ")
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
/**
* A live-collaboration presence layer: an avatar stack where users pop in on
* join and pop out on leave (scale from 0.9 + opacity, never from zero), and
* colored, labeled cursors glide to each collaborator's position via a spring
* that retargets on every move. Avatars overflow into a "+N" chip. Cursors are
* a decorative layer; reduced motion swaps them in place and drops the avatar
* pop.
*/
export function PresenceAvatars({
users,
max = 5,
static: isStatic = false,
className,
children,
...props
}: PresenceAvatarsProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const visible = users.slice(0, max);
const overflow = users.length - visible.length;
const cursors = users.filter((u) => u.cursor);
return (
<div
data-slot="presence-avatars"
className={cn(
"relative overflow-hidden rounded-xl bg-card shadow-border",
className,
)}
{...props}
>
{children}
{/* Cursor layer */}
<div className="pointer-events-none absolute inset-0" aria-hidden>
<AnimatePresence initial={false}>
{cursors.map((user) => (
<motion.div
key={user.id}
className="absolute top-0 left-0"
initial={animate ? { opacity: 0, scale: 0.9 } : false}
animate={{
opacity: 1,
scale: 1,
left: `${user.cursor!.x * 100}%`,
top: `${user.cursor!.y * 100}%`,
}}
exit={animate ? { opacity: 0, scale: 0.9 } : { opacity: 0 }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
style={{ filter: "drop-shadow(0 1px 1px rgba(0,0,0,0.25))" }}
>
<path
d="M3 2l11 5.5-4.7 1.4-1.9 4.6L3 2z"
fill={user.color}
stroke="var(--card)"
strokeWidth="1"
/>
</svg>
<span
className="ml-3 -mt-1 inline-block rounded-md px-1.5 py-0.5 text-[10px] font-medium text-white"
style={{ background: user.color }}
>
{user.name.split(" ")[0]}
</span>
</motion.div>
))}
</AnimatePresence>
</div>
{/* Avatar stack */}
<div className="absolute top-3 right-3 flex items-center">
<div className="flex -space-x-2">
<AnimatePresence initial={false} mode="popLayout">
{visible.map((user) => (
<motion.div
key={user.id}
layout={animate ? true : false}
initial={animate ? { opacity: 0, scale: 0.9 } : false}
animate={{ opacity: 1, scale: 1 }}
exit={animate ? { opacity: 0, scale: 0.9 } : { opacity: 0 }}
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
className="flex size-7 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-card"
style={{ background: user.color }}
title={user.name}
>
{initials(user.name)}
</motion.div>
))}
</AnimatePresence>
</div>
{overflow > 0 && (
<span className="ml-1 flex h-7 items-center rounded-full bg-muted px-2 text-[10px] font-medium tabular-nums text-muted-foreground ring-2 ring-card">
+{overflow}
</span>
)}
</div>
<span className="sr-only" aria-live="polite">
{users.length} people editing
</span>
</div>
);
}