AI Patterns

collaboration

Collaborative Presence

An avatar stack showing who has access to a shared record and who is actively viewing it right now.

Simulate who's viewing the record

"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";

export interface Collaborator {
  id: string;
  name: string;
  role: string;
  initials: string;
  /** Tailwind background color class, e.g. "bg-violet-500" */
  color: string;
  isOnline: boolean;
}

export interface CollaborativePresenceProps {
  collaborators: Collaborator[];
  /** Max avatars shown before collapsing into "+N" chip. Default 4. */
  visibleCap?: number;
  className?: string;
}

export function CollaborativePresence({
  collaborators,
  visibleCap = 4,
  className,
}: CollaborativePresenceProps) {
  const [panelOpen, setPanelOpen] = React.useState(false);
  const [hoveredId, setHoveredId] = React.useState<string | null>(null);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const panelId = React.useId();

  const sorted = React.useMemo(
    () => [...collaborators].sort((a, b) => Number(b.isOnline) - Number(a.isOnline)),
    [collaborators],
  );

  const visible = sorted.slice(0, visibleCap);
  const overflowCount = Math.max(0, sorted.length - visibleCap);
  const onlineCount = collaborators.filter((c) => c.isOnline).length;

  React.useEffect(() => {
    if (!panelOpen) return;
    function onOutsideClick(e: MouseEvent) {
      if (!containerRef.current?.contains(e.target as Node)) {
        setPanelOpen(false);
      }
    }
    document.addEventListener("mousedown", onOutsideClick);
    return () => document.removeEventListener("mousedown", onOutsideClick);
  }, [panelOpen]);

  const tooltipsActive = !panelOpen;

  return (
    <div ref={containerRef} className={cn("flex flex-col items-end", className)}>
      {/* Clickable avatar row */}
      <button
        type="button"
        aria-expanded={panelOpen}
        aria-controls={panelId}
        aria-label="View collaborators"
        onClick={() => {
          setPanelOpen((v) => !v);
          setHoveredId(null);
        }}
        className="flex items-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
      >
        <div className="flex -space-x-2">
          <AnimatePresence initial={false} mode="popLayout">
            {visible.map((collab, i) => (
              <AvatarPip
                key={collab.id}
                collab={collab}
                zIndex={visibleCap - i}
                showTooltip={tooltipsActive && hoveredId === collab.id}
                onMouseEnter={() => setHoveredId(collab.id)}
                onMouseLeave={() => setHoveredId(null)}
              />
            ))}
          </AnimatePresence>
        </div>

        {overflowCount > 0 && (
          <div
            className="relative ml-1 flex size-8 shrink-0 items-center justify-center rounded-full border-2 border-background bg-muted text-xs font-medium text-muted-foreground"
            onMouseEnter={() => setHoveredId("__overflow")}
            onMouseLeave={() => setHoveredId(null)}
          >
            +{overflowCount}
            <AnimatePresence>
              {tooltipsActive && hoveredId === "__overflow" && (
                <Tooltip>{overflowCount} more · click to see all</Tooltip>
              )}
            </AnimatePresence>
          </div>
        )}
      </button>

      {/* Inline detail panel — pushes page content down */}
      <AnimatePresence>
        {panelOpen && (
          <motion.div
            id={panelId}
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
            className="mt-2 w-64 overflow-hidden rounded-xl border bg-popover shadow-md"
          >
            <p className="px-3 py-2 text-xs text-muted-foreground">
              {onlineCount > 0
                ? `${onlineCount} online now · ${collaborators.length} with access`
                : `${collaborators.length} with access`}
            </p>
            <ul role="list">
              <AnimatePresence initial={false}>
                {sorted.map((collab) => (
                  <motion.li
                    key={collab.id}
                    layout
                    className="flex items-center gap-2.5 px-3 py-1.5"
                  >
                    <div className="relative shrink-0">
                      <div
                        className={cn(
                          "flex size-7 items-center justify-center rounded-full text-xs font-semibold text-white transition-opacity",
                          collab.color,
                          !collab.isOnline && "opacity-60",
                        )}
                      >
                        {collab.initials}
                      </div>
                      {collab.isOnline && (
                        <span
                          aria-hidden
                          className="absolute -bottom-0.5 -right-0.5 size-2 rounded-full bg-emerald-400 ring-1 ring-background"
                        />
                      )}
                    </div>
                    <div className="min-w-0 flex-1">
                      <p className="truncate text-xs font-medium leading-snug">{collab.name}</p>
                      <p className="truncate text-[10px] leading-snug text-muted-foreground">{collab.role}</p>
                    </div>
                    <span
                      className={cn(
                        "shrink-0 text-[10px]",
                        collab.isOnline
                          ? "text-emerald-500 dark:text-emerald-400"
                          : "text-muted-foreground",
                      )}
                    >
                      {collab.isOnline ? "Online" : "Has access"}
                    </span>
                  </motion.li>
                ))}
              </AnimatePresence>
            </ul>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

interface AvatarPipProps {
  collab: Collaborator;
  zIndex: number;
  showTooltip: boolean;
  onMouseEnter: () => void;
  onMouseLeave: () => void;
}

function AvatarPip({ collab, zIndex, showTooltip, onMouseEnter, onMouseLeave }: AvatarPipProps) {
  return (
    <motion.div
      layout
      initial={{ scale: 0.5, opacity: 0 }}
      animate={{ scale: 1, opacity: collab.isOnline ? 1 : 0.55 }}
      exit={{ scale: 0.5, opacity: 0 }}
      transition={{ type: "spring", stiffness: 380, damping: 28 }}
      style={{ zIndex }}
      className="relative shrink-0"
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      <div
        className={cn(
          "flex size-8 items-center justify-center rounded-full border-2 border-background text-xs font-semibold text-white",
          collab.color,
          collab.isOnline && "ring-2 ring-emerald-400",
        )}
      >
        {collab.initials}
      </div>

      <AnimatePresence>
        {collab.isOnline && (
          <motion.span
            key="dot"
            aria-hidden
            initial={{ scale: 0 }}
            animate={{ scale: 1 }}
            exit={{ scale: 0 }}
            className="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full bg-emerald-400 ring-1 ring-background"
          />
        )}
      </AnimatePresence>

      <AnimatePresence>
        {showTooltip && (
          <Tooltip>
            <span className="block font-semibold">{collab.name}</span>
            <span className="block text-muted-foreground">
              {collab.role} · {collab.isOnline ? "Online now" : "Has access"}
            </span>
          </Tooltip>
        )}
      </AnimatePresence>
    </motion.div>
  );
}

function Tooltip({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 6 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: 6 }}
      transition={{ duration: 0.15, ease: "easeOut" }}
      className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2.5 -translate-x-1/2 whitespace-nowrap rounded-md border bg-popover px-2.5 py-1.5 text-xs shadow-md"
    >
      {children}
    </motion.div>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Collaborative Presence

## Summary
An avatar stack that communicates two things at once: who has access to a shared project, and who is actively viewing it right now. The access list is always shown so the component is meaningful even when no one else is online. Online avatars are visually distinguished with a colored ring and a green dot. This "presence on top of access list" approach is the same convention used by Google Docs, Figma, and Notion, adapted for async B2B workflows where concurrent viewers are infrequent.

## When to use
- A project or document is shared across a fixed team working asynchronously.
- The product has a real-time presence signal scoped to who is currently viewing a specific item — not just who is logged in.
- The team is small enough (typically 2–10 people) that individual avatars are recognizable and meaningful.
- Knowing "who's involved" has persistent value, not just when others happen to be online.

## When not to use
- The item has no access control (everyone can see it) — use a viewer count or activity feed instead.
- Teams are so large (50+) that the avatar stack conveys nothing meaningful.
- Real-time presence is not technically available — showing only an access list without a presence signal produces a static component that doesn't justify its space.
- Consumer social products where follower counts matter more than specific collaborator identity.

## Anatomy
- **Avatar stack** — overlapping circular avatars in online-first order, up to the visible cap (default 4). The full group (stack + overflow chip) is a single button that toggles the detail panel.
- **Presence ring** — an emerald ring wrapping each avatar whose collaborator is currently viewing the item.
- **Online dot** — a small green dot at the bottom-right corner of each online avatar; backs up the ring with a redundant signal that does not rely on color alone.
- **Offline dimming** — avatars for collaborators not currently viewing appear at ~55% opacity, receding without disappearing.
- **Overflow chip** — a "+N" circle shown only when the collaborator count exceeds the visible cap. Hovering shows a tooltip: "N more · click to see all."
- **Detail panel** — an inline (not floating) panel that expands below the stack, pushing page content down. Lists all collaborators in online-first order with avatar, name, role, and a text status label.
- **Tooltip** — appears above an individual avatar on hover. Shows name on line one, and "Role · Online now" or "Role · Has access" on line two. Fades in with a slight nudge. Suppressed while the detail panel is open.

## Behavior

### Ordering
Online collaborators sort to the front of the stack. Within each group the order is stable. The stack should never be empty — show the current user's avatar even if they are the only person with access.

### Overflow surfacing
When a collaborator inside the overflow chip comes online they surface to the front of the visible stack via the join animation, and whoever was previously in the last visible slot moves to overflow. The "+N" count stays the same. The displaced avatar requires no explicit exit animation. The reverse (going offline while visible) lets the stack re-settle naturally without pulling an overflow collaborator forward.

### Join animation
The avatar appears at scale 0.5 / opacity 0 and springs to full size as the ring and dot fade in. Existing avatars slide to make room. Duration: ~300 ms (spring, stiffness 380, damping 28).

### Leave animation
The avatar shrinks and fades (scale 1→0.5, opacity 1→0) and settles into its new offline position. The ring and dot disappear with a separate scale-out transition.

### Detail panel
Opens inline on click, pushing page content down. Shows all collaborators in online-first order with avatar, name, role, and status label. A summary line at the top shows the counts: "2 online now · 5 with access." Clicking the avatar group again or clicking outside closes the panel. If presence changes while the panel is open the list re-sorts in place without requiring the user to reopen it.

### Hover tooltips
On desktop, hovering an avatar shows a tooltip above it with the collaborator's name and status. Tooltips are suppressed while the detail panel is open. On touch devices, tapping the group goes directly to the panel.

## Content guidelines
- Panel status labels: "Online" and "Has access."
- Tooltip status: "Role · Online now" or "Role · Has access" (tooltip uses the longer form for clarity on hover).
- Overflow tooltip: "N more · click to see all."
- Panel summary line: "2 online now · 5 with access." Use plain numbers, not percentages.
- Avatar initials: two characters maximum (first-name initial + last-name initial).
- If exactly one person has access, show their avatar alone — never leave the slot empty.

## Accessibility
- The entire avatar stack renders as a single `<button>` with `aria-label="View collaborators"` and `aria-expanded` reflecting the panel state.
- The detail panel is linked via `aria-controls` pointing to the button's `id`.
- Presence is not communicated by color alone: the panel provides text labels for every collaborator, and the tooltip repeats the same information on hover.
- The ring and online dot are decorative (`aria-hidden`); the panel list is the authoritative accessible representation.
- Avatar initials must maintain WCAG AA contrast against their background color.
- All animations should respect `prefers-reduced-motion`.

## Related patterns
- **Invite Members** — the access management counterpart; opens a modal to add or remove people from the same project. Typically placed adjacent to this component.
- **Sources Stack** — same overlapping circular group visual applied to source favicons. Shares the overlap, cap, and "+N" overflow conventions.