All patterns

Avatar Stack Overflow

Participants slide into the overlapping stack and the overflow count takes over.

socialfriendlyminimalautomatic · finite · intermediate · ~2.6s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

235 lines · react + motion only
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Avatar Stack Overflow
 *
 * A stack has a capacity, and the interesting moment is the one where
 * it is reached: after that, arrivals stop being faces and become a
 * number. Participants slide in from the trailing edge while the stack
 * shifts to make room; once it is full, the overflow count takes over
 * and rolls its digits at a constant size.
 *
 * Discs are initials on a tinted circle — no photo to wait for, and
 * nothing that scales, because the initials are text.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the stack reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `maxVisible`, `intervalMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type Participant = {
  id: string;
  name: string;
  initials: string;
  /** Disc color behind the initials — stands in for a photo. */
  tint: string;
};

export type AvatarStackOverflowProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** How many faces the stack shows before it starts counting. */
  maxVisible?: number;
  /** How many are already present on mount. */
  startWith?: number;
  /** Gap between arrivals, in ms. */
  intervalMs?: number;
  /** Roster to draw from. Falls back to a sample group. */
  people?: Participant[];
  /** Label above the stack. */
  context?: string;
};

type VariantConfig = {
  /** px each arrival travels in from. */
  travel: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** px the overflow digits roll through. */
  roll: number;
};

// Damping ratios (damping / 2√stiffness) stay at or above 0.8: an
// arrival settles once. Several discs settling twice, slightly out of
// phase, is the exact texture of a cheap UI.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a slide. For a header where people come and go all day.
  subtle: {
    travel: 8,
    spring: { type: "spring", stiffness: 520, damping: 42 },
    roll: 8,
  },
  // Enough travel to notice someone joined. All-purpose.
  default: {
    travel: 14,
    spring: { type: "spring", stiffness: 420, damping: 34 },
    roll: 11,
  },
  // A longer slide, for a live session where joining is the event.
  playful: {
    travel: 20,
    spring: { type: "spring", stiffness: 360, damping: 31 },
    roll: 13,
  },
};

const ROSTER: Participant[] = [
  { id: "ao", name: "Amara Osei", initials: "AO", tint: "#E08A3C" },
  { id: "tl", name: "Theo Lang", initials: "TL", tint: "#5B8DEF" },
  { id: "mk", name: "Maya Kwon", initials: "MK", tint: "#7C7CF0" },
  { id: "jv", name: "Jonas Vik", initials: "JV", tint: "#3FA98B" },
  { id: "ad", name: "Ana Duarte", initials: "AD", tint: "#4AA3B8" },
  { id: "ps", name: "Priya Sen", initials: "PS", tint: "#D2557A" },
  { id: "re", name: "Rowan Ellis", initials: "RE", tint: "#8A7CF0" },
];

/** Theme-adaptive neutral: mixing the text color in scope with
 *  `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function AvatarStackOverflow({
  variant = "default",
  maxVisible = 4,
  startWith = 2,
  intervalMs = 700,
  people = ROSTER,
  context = "Design review",
}: AvatarStackOverflowProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [joined, setJoined] = useState(Math.min(startWith, people.length));

  useEffect(() => {
    if (joined >= people.length) return;
    const timer = window.setTimeout(() => setJoined((count) => count + 1), intervalMs);
    return () => window.clearTimeout(timer);
  }, [joined, people.length, intervalMs]);

  const visible = people.slice(0, Math.min(joined, maxVisible));
  const overflow = Math.max(0, joined - maxVisible);

  // Reduced motion: people still arrive and the count still changes.
  // What goes is the travel, not the information.
  const travel = reduceMotion ? 0 : cfg.travel;
  const enter = reduceMotion
    ? { duration: 0.16, ease: "easeOut" as const }
    : cfg.spring;

  return (
    <div
      style={{
        width: 300,
        padding: "14px 16px 16px",
        borderRadius: 16,
        border: `1px solid ${tone(11)}`,
        background: tone(4),
        fontSize: 13.5,
      }}
    >
      <div style={{ fontSize: 12.5, fontWeight: 600 }}>{context}</div>
      <div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>
        {joined} {joined === 1 ? "person" : "people"} here
      </div>

      <motion.div
        aria-label={`${joined} participants`}
        style={{
          display: "flex",
          alignItems: "center",
          marginTop: 14,
          paddingLeft: 4,
        }}
      >
        {visible.map((person, index) => (
          <motion.span
            key={person.id}
            title={person.name}
            layout
            // Everyone already here on mount renders at rest; only real
            // arrivals travel.
            initial={index < startWith ? false : { opacity: 0, x: travel }}
            animate={{ opacity: 1, x: 0 }}
            transition={enter}
            style={{
              display: "grid",
              placeItems: "center",
              width: 34,
              height: 34,
              marginLeft: index === 0 ? 0 : -10,
              borderRadius: "50%",
              background: person.tint,
              color: "#ffffff",
              fontSize: 12,
              fontWeight: 650,
              // A ring in the page color is what separates one disc
              // from the one it overlaps, in either theme.
              boxShadow: "0 0 0 2px Canvas",
              zIndex: index,
            }}
          >
            {person.initials}
          </motion.span>
        ))}

        <AnimatePresence initial={false}>
          {overflow > 0 && (
            <motion.span
              key="overflow"
              layout
              initial={{ opacity: 0, x: travel }}
              animate={{ opacity: 1, x: 0 }}
              exit={{ opacity: 0, transition: { duration: 0.12 } }}
              transition={enter}
              style={{
                position: "relative",
                display: "grid",
                placeItems: "center",
                minWidth: 34,
                height: 34,
                marginLeft: -10,
                padding: "0 7px",
                borderRadius: 999,
                background: tone(10),
                border: `1px solid ${tone(14)}`,
                boxShadow: "0 0 0 2px Canvas",
                fontSize: 12,
                fontWeight: 600,
                overflow: "hidden",
                zIndex: visible.length,
              }}
            >
              {/* The chip never scales: the digits roll inside it, so
                  the text keeps a constant size. */}
              <AnimatePresence initial={false}>
                <motion.span
                  key={overflow}
                  initial={{ y: reduceMotion ? 0 : cfg.roll, opacity: 0 }}
                  animate={{ y: 0, opacity: 1 }}
                  exit={{ y: reduceMotion ? 0 : -cfg.roll, opacity: 0 }}
                  transition={{
                    duration: reduceMotion ? 0.14 : 0.22,
                    ease: [0.32, 0.72, 0, 1],
                  }}
                  style={{
                    position: "absolute",
                    inset: 0,
                    display: "grid",
                    placeItems: "center",
                  }}
                >
                  +{overflow}
                </motion.span>
              </AnimatePresence>
              {/* Reserves the width of the widest digit run without
                  ever being seen. */}
              <span style={{ visibility: "hidden" }}>+{overflow}</span>
            </motion.span>
          )}
        </AnimatePresence>
      </motion.div>
    </div>
  );
}

About this pattern

A stack has a capacity, and the interesting moment is the one where it is reached: after that, arrivals stop being faces and become a number. Each new participant slides in from the trailing edge while the stack shifts to make room, and once the stack is full the count picks up instead — swapping digits on a short vertical roll at a constant size, never scaling the chip. Discs are initials on a tinted circle, so the stack has no photo to wait for.

People joining a shared sessionAttendee list on an eventCollaborators on a documentGroup members in a header

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Members
    General
    Billing
    Security
    Integrations
    MembersNew
    Nils Bergströmnils@ridgeline.coAdmin
    Priya Ramanpriya@ridgeline.coMember
    Marcus Bellmarcus@ridgeline.coMember
    Dana Whitfielddana@ridgeline.coViewer
    Team members

    Faces gather in the corner and spill into a count once the row is full.

Related patterns