Layout

Presence Avatars

A live-collaboration presence layer: an avatar stack that pops on join and leave, plus colored labeled cursors that glide to each user.

Install

npx shadcn@latest add @paragon/presence-avatars

presence-avatars.tsx

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