All patterns

Avatar Group Load

Overlapping faces deal themselves out along the stack, then the overflow count lands.

loadingfriendlysubtleautomatic · finite · starter · ~0.7s
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.

219 lines · react + motion only
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Avatar Group Load
 *
 * An overlapping avatar stack that deals itself out left to right: each
 * face slides from behind the one before it and settles, about fifty
 * milliseconds apart, with the overflow count and the caption arriving
 * last.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Faces are synthesized from CSS gradients — no asset, no image host —
 * and the separating ring is mixed from the inherited text color, so the
 * stack reads on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `count`, `total`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type AvatarGroupLoadProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** How many faces are shown before the overflow chip. */
  count?: number;
  /** How many people there are in total; the chip carries the remainder. */
  total?: number;
  /** Avatar diameter in px. */
  size?: number;
  /** Line under the stack. Pass an empty string to drop it. */
  caption?: string;
  /** Accessible name for the group. */
  label?: string;
};

type VariantConfig = {
  /** Seconds between one face and the next. */
  stagger: number;
  /** Scale each face starts from. */
  from: number;
  /** How far each face slides out from behind its neighbour, in px. */
  slide: number;
  fadeSeconds: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the faces scale, the text never does. That is why the
// avatars carry a silhouette rather than initials — a two-letter monogram
// growing from 0.6 to 1 goes blurry on the way and reads as cheap. The
// overflow chip and the caption hold their size and only fade. Springs
// sit above critical damping, so each face lands once.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost simultaneous, almost no travel. For a dense table where a
  // stack like this appears on every row.
  subtle: {
    stagger: 0.035,
    from: 0.82,
    slide: 4,
    fadeSeconds: 0.2,
    spring: { type: "spring", stiffness: 620, damping: 46 },
  },
  // A readable deal from left to right. The all-purpose setting.
  default: {
    stagger: 0.055,
    from: 0.66,
    slide: 9,
    fadeSeconds: 0.24,
    spring: { type: "spring", stiffness: 500, damping: 40 },
  },
  // A wider deal for a single collaborator strip at the top of a
  // document, where the group is the headline.
  playful: {
    stagger: 0.08,
    from: 0.52,
    slide: 14,
    fadeSeconds: 0.28,
    spring: { type: "spring", stiffness: 420, damping: 36 },
  },
};

/** Faces stand in for photographs, so these stay literal — a photo
 *  placeholder is imagery, not a surface. */
const FACES = [
  "linear-gradient(140deg, #7C7CF0 0%, #4B4BB8 100%)",
  "linear-gradient(140deg, #F0A17C 0%, #C2603A 100%)",
  "linear-gradient(140deg, #4FBFA8 0%, #2A7F73 100%)",
  "linear-gradient(140deg, #E87CA8 0%, #A64478 100%)",
  "linear-gradient(140deg, #7CB4F0 0%, #386CC0 100%)",
  "linear-gradient(140deg, #C6A24F 0%, #8A6620 100%)",
];

/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
 *  a ring mixed from it separates one face from the next on a light page
 *  and on a dark one without the component ever knowing the page's
 *  background color. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function AvatarGroupLoad({
  variant = "default",
  count = 5,
  total = 12,
  size = 34,
  caption = "Ava Chen, Marco Diaz and 10 others are watching this ticket",
  label = "People on this ticket",
}: AvatarGroupLoadProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const shown = Math.max(0, Math.min(count, FACES.length));
  const overflow = Math.max(0, total - shown);
  // Reduced motion keeps the group and drops the deal: one fade, no
  // stagger, no travel, no scale.
  const stagger = reduceMotion ? 0 : cfg.stagger;
  const tail = stagger * shown;

  return (
    <div role="group" aria-label={`${label}: ${total}`}>
      <div style={{ display: "flex", alignItems: "center" }}>
        {Array.from({ length: shown }, (_, index) => (
          <motion.span
            key={index}
            aria-hidden
            initial={{
              opacity: 0,
              scale: reduceMotion ? 1 : cfg.from,
              x: reduceMotion ? 0 : -cfg.slide,
            }}
            animate={{ opacity: 1, scale: 1, x: 0 }}
            transition={{
              opacity: {
                duration: cfg.fadeSeconds,
                ease: "easeOut",
                delay: index * stagger,
              },
              default: reduceMotion
                ? { duration: 0 }
                : { ...cfg.spring, delay: index * stagger },
            }}
            style={{
              width: size,
              height: size,
              borderRadius: "50%",
              flexShrink: 0,
              display: "grid",
              placeItems: "center",
              background: FACES[index % FACES.length],
              // Earlier faces sit on top, so each new one appears to slide
              // out from behind its neighbour rather than over it.
              zIndex: shown - index,
              marginLeft: index === 0 ? 0 : -Math.round(size * 0.3),
              boxShadow: `0 0 0 2px ${tone(18)}`,
            }}
          >
            <svg
              width={Math.round(size * 0.52)}
              height={Math.round(size * 0.52)}
              viewBox="0 0 20 20"
              fill="none"
            >
              <circle cx="10" cy="7.4" r="3.1" fill="rgba(255,255,255,0.72)" />
              <path
                d="M3.8 17.2a6.2 6.2 0 0 1 12.4 0"
                fill="rgba(255,255,255,0.72)"
              />
            </svg>
          </motion.span>
        ))}

        {overflow > 0 ? (
          // Text: it fades and slides, never scales.
          <motion.span
            initial={{ opacity: 0, x: reduceMotion ? 0 : -cfg.slide }}
            animate={{ opacity: 1, x: 0 }}
            transition={{
              opacity: {
                duration: cfg.fadeSeconds,
                ease: "easeOut",
                delay: tail,
              },
              x: reduceMotion ? { duration: 0 } : { ...cfg.spring, delay: tail },
            }}
            style={{
              height: size,
              minWidth: size,
              padding: "0 8px",
              borderRadius: 999,
              flexShrink: 0,
              display: "grid",
              placeItems: "center",
              background: tone(9),
              boxShadow: `0 0 0 2px ${tone(18)}`,
              marginLeft: -Math.round(size * 0.3),
              fontSize: Math.round(size * 0.35),
              fontWeight: 600,
              fontVariantNumeric: "tabular-nums",
              lineHeight: 1,
            }}
          >
            +{overflow}
          </motion.span>
        ) : null}
      </div>

      {caption ? (
        <motion.div
          initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
          animate={{ opacity: 0.6, y: 0 }}
          transition={{
            duration: reduceMotion ? cfg.fadeSeconds : 0.3,
            ease: "easeOut",
            delay: tail + (reduceMotion ? 0 : 0.06),
          }}
          style={{ marginTop: 11, fontSize: 12, lineHeight: 1.45 }}
        >
          {caption}
        </motion.div>
      ) : null}
    </div>
  );
}

About this pattern

A collaborator stack that populates after the body of the page has already rendered. Each face slides out from behind the one before it and settles about fifty milliseconds later, which gives the eye a direction of travel through what is otherwise a clump of circles; the overflow chip and the caption arrive last, so the group reads as a count rather than an unfinished list. Earlier faces keep the higher z-index, so a new one appears to come from behind its neighbour instead of landing on top of it. The faces carry a silhouette rather than initials on purpose: they scale on entry, and a two-letter monogram growing from two-thirds size goes blurry on the way.

Collaborator stripTicket watchersAttendee listTeam roster

Where it shows up

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

  • Design canvas

    Collaborator avatars populate the toolbar as each session connects.

Related patterns