Skip to content

Live faces that slide in, ping once, fade out and roll the overflow count.

Collaborationmotion@base-ui/react@number-flow/react

01Preview

Q3 pricing proposal

Edited 2m ago

Select a face to follow their view

Pricing. Move Team to $18 per seat, billed yearly, and keep the free tier at 3 editors.

Open question: do we grandfather accounts created before June?

q3-forecast.xlsx
LFPR

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @base-ui/react @number-flow/react

03Usage

import { PresenceStack } from "@/components/ui/presence-stack";

<PresenceStack people={viewers} />

// Faces become buttons: follow someone, and ring whoever you're following.
<PresenceStack
  people={viewers}
  max={5}
  activeId={following}
  onPersonClick={(p) => setFollowing(p.id === following ? null : p.id)}
/>

04Source

"use client";
import { Avatar } from "@base-ui/react/avatar";
import { Popover } from "@base-ui/react/popover";
import { Tooltip } from "@base-ui/react/tooltip";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";

export type PresencePerson = {
  /** Stable id from your presence channel. Keys the arrival and exit animations. */
  id: string;
  name: string;
  src?: string;
  /** A short second line: "Editing Pricing", "Viewing", "Idle 4m". */
  meta?: string;
  /** Connected but not active. Their face dims; they stay in the stack. */
  idle?: boolean;
  /** The viewer themselves. Labeled "(you)" and never announced. */
  self?: boolean;
};

type Size = "sm" | "md" | "lg";
const sizes: Record<Size, number> = { sm: 24, md: 28, lg: 32 };

/* -------------------------------------------------------------------------------------------------
 * Faces
 * -----------------------------------------------------------------------------------------------*/

function initials(name: string) {
  const words = name.replace(/\(.*?\)/g, " ").split("@")[0].trim().split(/[\s._-]+/).filter(Boolean);
  const first = (w: string) => Array.from(w)[0] ?? "";
  if (!words.length) return "";
  if (words.length === 1) return first(words[0]).toUpperCase();
  return (first(words[0]) + first(words[words.length - 1])).toUpperCase();
}

// Steps of the foreground, not hues: people are told apart by initials and names, and color
// stays reserved for meaning. The same id always lands on the same step.
const tones = ["bg-fg/[0.07] text-fg-2", "bg-fg/[0.1] text-fg-2", "bg-fg/[0.13] text-fg", "bg-fg/[0.17] text-fg", "bg-fg/[0.22] text-fg"];
function tone(key: string) {
  let h = 0x811c9dc5;
  for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 0x01000193);
  return tones[(h >>> 0) % tones.length];
}

// `shift` moves initials right when a neighbor covers the left edge, so they center in what shows.
function Face({ person, size, mask, shift = 0, active }: { person: PresencePerson; size: number; mask?: string; shift?: number; active?: boolean }) {
  return (
    <Avatar.Root
      data-idle={person.idle || undefined}
      className="relative block shrink-0 overflow-hidden rounded-full transition-[opacity,filter] duration-300 ease-out-quart data-idle:opacity-45 data-idle:grayscale"
      style={{ width: size, height: size, maskImage: mask, WebkitMaskImage: mask }}
    >
      <Avatar.Fallback
        className={cn("absolute inset-0 grid place-items-center font-medium leading-none tracking-[0.01em]", tone(person.id))}
        style={{ fontSize: Math.round(size * 0.38 * 2) / 2, paddingLeft: shift }}
      >
        {initials(person.name)}
      </Avatar.Fallback>
      {person.src && (
        <Avatar.Image
          src={person.src}
          alt=""
          className="absolute inset-0 size-full object-cover transition-opacity duration-200 ease-out-quart data-starting-style:opacity-0"
        />
      )}
      {/* The follow ring draws inward from the edge: a foreground line, then a gap, then the face. */}
      <span
        aria-hidden
        data-active={active || undefined}
        className={cn(
          "pointer-events-none absolute inset-0 rounded-full transition-[box-shadow] duration-240 ease-out-expo",
          "shadow-[inset_0_0_0_0_var(--fg),inset_0_0_0_0_var(--frame)]",
          "data-active:shadow-[inset_0_0_0_1.5px_var(--fg),inset_0_0_0_3px_var(--frame)]",
        )}
      />
    </Avatar.Root>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Words
 * -----------------------------------------------------------------------------------------------*/

const displayName = (p: PresencePerson) => (p.self ? `${p.name} (you)` : p.name);
const list = new Intl.ListFormat("en", { type: "conjunction" });

function summarize(label: string, people: PresencePerson[]) {
  const names = people.map(displayName);
  if (names.length <= 3) return `${label}: ${list.format(names)}`;
  return `${label}: ${names.slice(0, 2).join(", ")} and ${names.length - 2} others`;
}

function shortNames(people: PresencePerson[]) {
  const first = people.slice(0, 2).map((p) => (p.self ? "You" : p.name.split(/\s+/)[0]));
  const rest = people.length - first.length;
  return rest > 0 ? `${first.join(", ")} and ${rest} more` : list.format(first);
}

// "Maya Okafor joined", "Jon Park and Lena Fischer left", "3 people joined".
function change(people: PresencePerson[], verb: "joined" | "left") {
  const others = people.filter((p) => !p.self);
  if (!others.length) return "";
  if (others.length > 2) return `${others.length} people ${verb}`;
  return `${list.format(others.map((p) => p.name))} ${verb}`;
}

/* -------------------------------------------------------------------------------------------------
 * PresenceStack
 * -----------------------------------------------------------------------------------------------*/

type Tip = { title: string; meta?: string; hint?: string };

export type PresenceStackProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** Everyone connected right now, in the order to draw them. */
  people: PresencePerson[];
  /** Most circles drawn, the +N chip included. One extra person is drawn rather than a "+1". */
  max?: number;
  size?: Size;
  /** What the stack is. Names the group and titles the full list. */
  label?: string;
  /** Makes each face a button: follow them, jump to their cursor. */
  onPersonClick?: (person: PresencePerson) => void;
  /** The person being followed. Their face gets a ring that draws in. */
  activeId?: string | null;
  /** Tooltip hint on faces when they are buttons. */
  actionHint?: string;
  /** Announce arrivals and departures to screen readers. */
  announce?: boolean;
  /** Placeholder faces while the presence channel connects. */
  loading?: boolean;
  /** Rendered when nobody is here. Nothing by default. */
  empty?: React.ReactNode;
  /** Where the tooltip and list portal. Defaults to document.body. */
  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;
};

/**
 * Who is here right now. Arrivals slide in and ping once, leavers fade while the rest close
 * ranks, the overflow count rolls, and one tooltip glides from face to face.
 */
export function PresenceStack({
  people,
  max = 4,
  size = "md",
  label = "Viewing now",
  onPersonClick,
  activeId = null,
  actionHint = "Click to follow",
  announce = true,
  loading = false,
  empty = null,
  container,
  className,
  onKeyDown,
  ...rest
}: PresenceStackProps) {
  const reduce = useReducedMotion();
  const [tip] = useState(() => Tooltip.createHandle<Tip>());
  const [open, setOpen] = useState(false);
  const [focusIndex, setFocusIndex] = useState(0);

  // Diff the list as it changes, during render, so the announcement lands with the new faces.
  // Only the latest arrivals ping: a face that slides out of the overflow later is not news.
  const [prev, setPrev] = useState(people);
  const [message, setMessage] = useState("");
  const [fresh, setFresh] = useState<Set<string>>(() => new Set());
  if (prev !== people) {
    const before = new Set(prev.map((p) => p.id));
    const after = new Set(people.map((p) => p.id));
    const arrivals = people.filter((p) => !before.has(p.id));
    const joined = change(arrivals, "joined");
    const left = change(prev.filter((p) => !after.has(p.id)), "left");
    setPrev(people);
    if (arrivals.length || left) setFresh(new Set(arrivals.map((p) => p.id)));
    if (joined || left) setMessage([joined, left].filter(Boolean).join(". "));
  }

  const s = sizes[size];
  const overlap = Math.round(s * 0.28);
  const gap = s >= 28 ? 2 : 1.5;
  const cap = Math.max(2, max);
  const fits = people.length <= cap;
  const shown = fits ? people : people.slice(0, cap - 1);
  const hidden = fits ? [] : people.slice(cap - 1);
  const interactive = !!onPersonClick;
  const count = shown.length + (hidden.length ? 1 : 0);
  const current = Math.min(focusIndex, Math.max(0, count - 1));

  // Each circle after the first has the one before it punched out, plus a hairline of air, so the
  // stack reads on any surface: a card, a hover wash, a photo. The opaque stop only lends alpha.
  const cut = `radial-gradient(circle at ${overlap - s / 2}px 50%, transparent ${s / 2 + gap}px, var(--fg) ${s / 2 + gap + 0.5}px)`;
  const slot = (i: number): React.CSSProperties => ({ zIndex: 20 - i, marginLeft: i === 0 ? 0 : -overlap });

  const enter = reduce ? { opacity: 0 } : { opacity: 0, x: 8, scale: 0.7 };
  const leave = reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.7, filter: "blur(2px)", transition: { duration: 0.16, ease: ease.out } };
  const transition = reduce ? { duration: 0.15 } : { ...spring.pop, opacity: { duration: 0.18, ease: ease.out }, layout: spring.snappy };

  // Roving focus: one tab stop for the whole stack, arrows move between faces.
  const move = (e: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(e);
    if (!interactive || e.defaultPrevented) return;
    const last = count - 1;
    const next = e.key === "ArrowRight" ? Math.min(last, current + 1) : e.key === "ArrowLeft" ? Math.max(0, current - 1) : e.key === "Home" ? 0 : e.key === "End" ? last : -1;
    if (next < 0) return;
    e.preventDefault();
    setFocusIndex(next);
    e.currentTarget.querySelectorAll<HTMLElement>("[data-presence-item]")[next]?.focus();
  };

  if (loading)
    return (
      <div role="img" aria-busy aria-label={`Loading ${label.toLowerCase()}`} className={cn("inline-flex", className)} {...rest}>
        {[0, 1, 2].map((i) => (
          <span
            key={i}
            className="block animate-pulse-soft rounded-full bg-hover motion-reduce:animate-none"
            style={{ ...slot(i), width: s, height: s, maskImage: i ? cut : undefined, WebkitMaskImage: i ? cut : undefined }}
          />
        ))}
      </div>
    );

  const live = announce && (
    <span role="status" aria-live="polite" className="sr-only">
      {message}
    </span>
  );

  if (people.length === 0)
    return (
      <>
        {empty}
        {live}
      </>
    );

  return (
    <div
      role={interactive ? "toolbar" : "group"}
      aria-label={summarize(label, people)}
      onKeyDown={move}
      className={cn("relative isolate inline-flex items-center", className)}
      {...rest}
    >
      <AnimatePresence initial={false} mode="popLayout">
        {shown.map((p, i) => {
          const arrived = fresh.has(p.id);
          const active = p.id === activeId;
          const face = <Face person={p} size={s} mask={i === 0 ? undefined : cut} shift={i === 0 ? 0 : Math.round(overlap / 2)} active={active} />;
          const tipPayload: Tip = { title: displayName(p), meta: p.idle && !p.meta ? "Idle" : p.meta, hint: interactive && !p.self ? (active ? "Following" : actionHint) : undefined };
          return (
            <motion.span
              key={p.id}
              layout={!reduce}
              initial={enter}
              animate={{ opacity: 1, x: 0, scale: 1, filter: "blur(0px)" }}
              exit={leave}
              transition={transition}
              className="relative flex rounded-full"
              style={slot(i)}
            >
              {/* A single ring that ripples out once, so a new face is noticed without a toast. */}
              {arrived && !reduce && (
                <motion.span
                  aria-hidden
                  className="pointer-events-none absolute inset-0 rounded-full border border-fg"
                  initial={{ scale: 1, opacity: 0.5 }}
                  animate={{ scale: 1.6, opacity: 0 }}
                  transition={{ duration: 0.9, ease: ease.out, delay: 0.12 }}
                />
              )}
              <Tooltip.Trigger
                handle={tip}
                payload={tipPayload}
                delay={350}
                render={
                  interactive ? (
                    <button
                      type="button"
                      data-presence-item=""
                      aria-label={`${displayName(p)}${p.meta ? `, ${p.meta}` : ""}`}
                      aria-pressed={active}
                      tabIndex={i === current ? 0 : -1}
                      onFocus={() => setFocusIndex(i)}
                      onClick={() => onPersonClick?.(p)}
                      className={cn(
                        "relative flex rounded-full outline-none",
                        "transition-[translate,scale] duration-150 ease-out-quart hover:-translate-y-0.5 active:scale-[0.92] active:duration-75",
                        "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
                        "motion-reduce:hover:translate-y-0",
                      )}
                    />
                  ) : (
                    <span className="flex rounded-full" />
                  )
                }
              >
                {face}
              </Tooltip.Trigger>
            </motion.span>
          );
        })}

        {hidden.length > 0 && (
          <motion.span
            key="overflow"
            layout={!reduce}
            initial={enter}
            animate={{ opacity: 1, x: 0, scale: 1, filter: "blur(0px)" }}
            exit={leave}
            transition={transition}
            className="relative flex rounded-full"
            style={slot(shown.length)}
          >
            <Popover.Root open={open} onOpenChange={setOpen}>
              <Popover.Trigger
                data-presence-item=""
                aria-label={`${hidden.length} more: ${hidden.map(displayName).join(", ")}. Show everyone`}
                tabIndex={interactive && shown.length !== current ? -1 : 0}
                onFocus={() => setFocusIndex(shown.length)}
                className={cn(
                  "group/more relative flex rounded-full outline-none",
                  "transition-[scale] duration-150 ease-out-quart active:scale-[0.92] active:duration-75",
                  "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
                  // Draws at 24–32px; on touch the target grows to 44px without moving anything.
                  "before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
                )}
              >
                <Tooltip.Trigger
                  handle={tip}
                  payload={{ title: shortNames(hidden) }}
                  delay={350}
                  render={
                    <span
                      className={cn(
                        "grid place-items-center rounded-full bg-fg/[0.08] font-medium leading-none text-fg-2 tabular",
                        "transition-colors duration-150 group-hover/more:bg-fg/[0.13] group-hover/more:text-fg group-data-popup-open/more:bg-fg/[0.13] group-data-popup-open/more:text-fg",
                      )}
                      style={{ width: s, height: s, fontSize: s >= 28 ? 11 : 10.5, paddingLeft: shown.length ? Math.round(overlap / 2) : 0, maskImage: shown.length ? cut : undefined, WebkitMaskImage: shown.length ? cut : undefined }}
                    />
                  }
                >
                  <NumberFlow value={hidden.length} prefix="+" className="leading-none" />
                </Tooltip.Trigger>
              </Popover.Trigger>
              <Popover.Portal container={container}>
                <Popover.Positioner side="bottom" align="end" sideOffset={8} collisionPadding={8} className="z-(--z-popover)">
                  <Popover.Popup
                    className={cn(
                      "flex w-64 max-w-(--available-width) flex-col rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none",
                      "origin-(--transform-origin) transition-[opacity,scale,translate,filter] duration-200 ease-out-expo",
                      "data-starting-style:-translate-y-1 data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]",
                      "data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quart",
                      "data-instant:transition-none motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none",
                    )}
                  >
                    <div className="flex h-9 shrink-0 items-center justify-between gap-3 border-b border-line px-3">
                      <Popover.Title className="truncate text-[12.5px] font-medium text-fg">{label}</Popover.Title>
                      <NumberFlow value={people.length} className="font-mono text-2xs text-fg-3" />
                    </div>
                    <ul
                      aria-label={label}
                      className="max-h-[232px] overflow-y-auto overscroll-contain p-1 [mask-image:linear-gradient(to_bottom,transparent,var(--fg)_6px,var(--fg)_calc(100%-6px),transparent)]"
                    >
                      {people.map((p) => {
                        const active = p.id === activeId;
                        const row = (
                          <>
                            <Face person={p} size={24} active={active} />
                            <span className="min-w-0 flex-1 truncate text-left text-[13px] text-fg">{displayName(p)}</span>
                            <span className={cn("max-w-[45%] shrink-0 truncate text-[12px]", active ? "text-fg" : "text-fg-3")}>
                              {active ? "Following" : p.idle && !p.meta ? "Idle" : p.meta}
                            </span>
                          </>
                        );
                        return (
                          <li key={p.id}>
                            {interactive && !p.self ? (
                              <button
                                type="button"
                                aria-pressed={active}
                                onClick={() => {
                                  onPersonClick?.(p);
                                  setOpen(false);
                                }}
                                className={cn(
                                  "flex h-10 w-full items-center gap-2.5 rounded-lg px-2 outline-none",
                                  "transition-colors duration-150 hover:bg-hover focus-visible:bg-hover",
                                  "focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4",
                                )}
                              >
                                {row}
                              </button>
                            ) : (
                              <div className="flex h-10 items-center gap-2.5 px-2">{row}</div>
                            )}
                          </li>
                        );
                      })}
                    </ul>
                  </Popover.Popup>
                </Popover.Positioner>
              </Popover.Portal>
            </Popover.Root>
          </motion.span>
        )}
      </AnimatePresence>

      {/* One tooltip for the whole stack. It glides between faces instead of closing and reopening. */}
      <Tooltip.Root handle={tip} disabled={open}>
        {({ payload }) => (
          <Tooltip.Portal container={container}>
            <Tooltip.Positioner
              side="top"
              sideOffset={8}
              collisionPadding={8}
              className="z-(--z-tooltip) h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-200 ease-out-quart data-instant:transition-none"
            >
              <Tooltip.Popup
                className={cn(
                  "h-(--popup-height,auto) w-(--popup-width,auto) overflow-clip rounded-lg border border-line-2 bg-raised text-[12px] leading-4 text-fg shadow-pop outline-none",
                  "origin-(--transform-origin) transition-[opacity,scale,width,height] [transition-duration:150ms,150ms,200ms,200ms] ease-out-expo",
                  "data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:[transition-duration:100ms]",
                  "data-instant:transition-none motion-reduce:data-starting-style:scale-100",
                )}
              >
                <Tooltip.Viewport
                  className={cn(
                    "relative h-full w-full",
                    "[&>*]:flex [&>*]:w-max [&>*]:max-w-[16rem] [&>*]:flex-col [&>*]:px-2 [&>*]:py-[5px]",
                    "[&>[data-previous]]:absolute [&>[data-previous]]:left-0 [&>[data-previous]]:top-0",
                    "[&>*]:transition-[opacity,filter] [&>*]:duration-150",
                    "[&>[data-current][data-starting-style]]:opacity-0 [&>[data-current][data-starting-style]]:blur-[2px]",
                    "[&>[data-previous][data-ending-style]]:opacity-0 [&>[data-previous]]:duration-100",
                  )}
                >
                  {payload && (
                    <>
                      <span className="flex items-baseline gap-1.5">
                        <span className="truncate">{payload.title}</span>
                        {payload.meta && <span className="shrink-0 text-fg-3">{payload.meta}</span>}
                      </span>
                      {payload.hint && <span className="text-2xs text-fg-3">{payload.hint}</span>}
                    </>
                  )}
                </Tooltip.Viewport>
              </Tooltip.Popup>
            </Tooltip.Positioner>
          </Tooltip.Portal>
        )}
      </Tooltip.Root>
      {live}
    </div>
  );
}

05Props

PropTypeDefaultDescription
people*PresencePerson[]Everyone connected now, in draw order: { id, name, src?, meta?, idle?, self? }. Keyed by id, so arrivals and exits animate.
maxnumber4Most circles drawn, the +N chip included. One extra person is drawn instead of a “+1”.
size"sm" | "md" | "lg""md"24, 28 or 32px faces.
labelstring"Viewing now"Names the group for screen readers and titles the full list.
onPersonClick(person: PresencePerson) => voidTurns each face and list row into a button. Use it to follow someone or jump to their cursor.
activeIdstring | nullnullThe person being followed. Their face gets a ring that draws inward.
actionHintstring"Click to follow"Second line of the tooltip when faces are buttons.
announcebooleantrueReads out who joined and left through a polite live region.
loadingbooleanfalseThree placeholder circles while the presence channel connects.
emptyReactNodenullRendered when nobody is connected.
containerHTMLElement | RefObject | nullWhere the tooltip and list portal. Defaults to document.body.

06Notes

Behavior

  • Each face after the first has the one before it cut out, plus a 1.5–2px gap, so the stack reads on cards, hover washes and photos without assuming the background color. Initials and the +N shift right to center in the part that shows.
  • Past max, the last slot becomes a +N button that opens everyone in a list, idle people dimmed and whoever you follow marked. The count rolls instead of swapping.
  • Only people in the latest change ping. A face that slides out of the overflow because someone else left is not treated as an arrival.
  • Idle people stay in place at 45% opacity and in grayscale, so the stack doesn't reshuffle every time someone steps away.

Motion

  • Arrivals come in from 8px right and 0.7 scale on the pop spring (600/30); neighbors make room on the snappy spring (520/38) through layout animation.
  • Leavers fade to 0.7 scale with a 2px blur in 160ms and are lifted out of the flow at once, so the rest close ranks while they fade.
  • A new arrival sends one 1px ring out to 1.6× over 900ms. The follow ring draws inward as an inset shadow in 240ms. Reduced motion keeps 150ms fades and drops the travel, scale, ping and blur.

Accessibility

  • With onPersonClick the stack is a toolbar with one tab stop: arrows move between faces, Home and End jump, Enter or Space toggles. Each face is a pressed or unpressed button named by the person and their activity.
  • Without it, the group’s label lists who is here (“Viewing now: Maya Okafor, Jon Park and 3 others”), and the +N button opens the full list.
  • Joins and leaves are announced politely (“Lena Fischer joined”). The viewer’s own entry is never announced.