All patterns

Group Join

A joining member's avatar drops into the stack, the others make room, and the count rolls by one.

socialfriendlyenergeticautomatic · 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.

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

/**
 * Vibary · Group Join
 *
 * Someone joins: their avatar drops into the front of the stack, the
 * others make room, and the member count rolls over by one. Three small
 * movements that all describe the same single event.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Avatars are monograms on a colored disc — no image assets — and
 * surfaces are mixed from the inherited text color, so the header reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `groupName`, `joinDelaysMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type GroupJoinAvatarProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  groupName?: string;
  /** Members already in the group before anyone joins. */
  startingCount?: number;
  /** When each scripted join lands, in ms from mount. */
  joinDelaysMs?: number[];
  /** Called with the new total on each join. */
  onJoin?: (total: number) => void;
};

type VariantConfig = {
  /** px the arriving avatar falls from. */
  drop: number;
  /** Seconds for the count to roll over. */
  roll: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: springs stay at or above a 0.8 damping ratio
// (damping / 2√stiffness). The count rolls on a tween rather than a
// spring on purpose — numerals that overshoot and come back read as
// broken, and text is never allowed to bounce. Variants differ in how far
// the new avatar falls and how quickly the stack resettles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A short drop and a tight resettle. For a header that updates often.
  subtle: {
    drop: 2,
    roll: 0.24,
    spring: { type: "spring", stiffness: 710, damping: 49 },
  },
  // Enough travel to notice someone arrived. All-purpose.
  default: {
    drop: 9,
    roll: 0.3,
    spring: { type: "spring", stiffness: 500, damping: 40 },
  },
  // A longer fall and a slower shuffle, for a small, social group.
  playful: {
    drop: 17,
    roll: 0.4,
    spring: { type: "spring", stiffness: 350, damping: 32 },
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` yields surfaces and borders correctly toned on a light
 *  page and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

type Member = { id: number; initials: string; name: string; disc: string };

/** Disc colors stand in for a photo, so they stay literal. */
const SEATED: readonly Member[] = [
  { id: 1, initials: "PR", name: "Priya Raman", disc: "linear-gradient(140deg,#4C7DF0,#7C5AE8)" },
  { id: 2, initials: "TL", name: "Tomas Lund", disc: "linear-gradient(140deg,#3FA98C,#2C7F8F)" },
  { id: 3, initials: "AO", name: "Adaeze Okoro", disc: "linear-gradient(140deg,#F0A24C,#E0577F)" },
  { id: 4, initials: "JW", name: "Jonah Weiss", disc: "linear-gradient(140deg,#8A8FA3,#5C6274)" },
];

const JOINERS: readonly Member[] = [
  { id: 5, initials: "MV", name: "Marisol Vega", disc: "linear-gradient(140deg,#E0577F,#A94CE0)" },
  { id: 6, initials: "KH", name: "Kenji Hara", disc: "linear-gradient(140deg,#4CC0E0,#4C7DF0)" },
];

const AVATAR = 30;
const VISIBLE = 4;

export default function GroupJoinAvatar({
  variant = "default",
  groupName = "Product Guild",
  startingCount = 12,
  joinDelaysMs = [900, 2300],
  onJoin,
}: GroupJoinAvatarProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [joined, setJoined] = useState(0);

  // The callback is read through a ref so that passing an inline function
  // doesn't reschedule the joins on every render.
  const notify = useRef(onJoin);
  useEffect(() => {
    notify.current = onJoin;
  }, [onJoin]);

  // A primitive dependency: the default array is a fresh identity on every
  // render, and depending on it directly would restart the timers each time.
  const schedule = joinDelaysMs.join(",");
  useEffect(() => {
    const timers = schedule
      .split(",")
      .map(Number)
      .slice(0, JOINERS.length)
      .map((delay, index) =>
        setTimeout(() => {
          setJoined(index + 1);
          notify.current?.(startingCount + index + 1);
        }, delay)
      );
    return () => timers.forEach(clearTimeout);
  }, [schedule, startingCount]);

  const roster = [...JOINERS.slice(0, joined).reverse(), ...SEATED];
  const stack = roster.slice(0, VISIBLE);
  const total = startingCount + joined;
  const latest = joined > 0 ? JOINERS[joined - 1] : null;

  // Reduced motion: the avatar is in the stack and the number is the new
  // number — the event still lands, it simply does not travel to get there.
  const settle = reduceMotion ? { duration: 0 } : cfg.spring;

  return (
    <div
      style={{
        width: 320,
        padding: "15px 16px 13px",
        borderRadius: 18,
        border: `1px solid ${tone(11)}`,
        background: tone(4),
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <div
          style={{ display: "flex", alignItems: "center", flexShrink: 0 }}
          aria-hidden
        >
          <AnimatePresence initial={false}>
            {stack.map((member, index) => (
              <motion.span
                key={member.id}
                // layout="position" moves the discs without stretching
                // them, so the monograms never scale as the stack shuffles.
                layout={reduceMotion ? false : "position"}
                initial={{ opacity: 0, y: -cfg.drop }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0 }}
                transition={{
                  opacity: { duration: 0.2, ease: "easeOut" },
                  y: settle,
                  layout: settle,
                }}
                style={{
                  width: AVATAR,
                  height: AVATAR,
                  marginLeft: index === 0 ? 0 : -9,
                  zIndex: VISIBLE - index,
                  borderRadius: "50%",
                  display: "grid",
                  placeItems: "center",
                  fontSize: 11,
                  fontWeight: 600,
                  color: "#fff",
                  background: member.disc,
                  boxShadow: "0 0 0 2px Canvas",
                }}
              >
                {member.initials}
              </motion.span>
            ))}
          </AnimatePresence>
          <motion.span
            layout={reduceMotion ? false : "position"}
            transition={{ layout: settle }}
            style={{
              width: AVATAR,
              height: AVATAR,
              marginLeft: -9,
              borderRadius: "50%",
              display: "grid",
              placeItems: "center",
              fontSize: 10.5,
              fontWeight: 600,
              background: tone(12),
              boxShadow: "0 0 0 2px Canvas",
            }}
          >
            +{Math.max(total - VISIBLE, 0)}
          </motion.span>
        </div>

        <div style={{ minWidth: 0, flex: 1 }}>
          <div style={{ fontSize: 14, fontWeight: 650, lineHeight: 1.3 }}>
            {groupName}
          </div>
          <div
            style={{
              display: "flex",
              alignItems: "center",
              gap: 4,
              fontSize: 11.5,
              opacity: 0.55,
              marginTop: 2,
            }}
          >
            <CountRoll
              value={total}
              seconds={reduceMotion ? 0 : cfg.roll}
              travel={!reduceMotion}
            />
            members
          </div>
        </div>
      </div>

      {/* Fixed-height slot: the line changes wording without the header
          growing a row and pushing the group's content down. */}
      <div
        aria-live="polite"
        style={{
          position: "relative",
          height: 16,
          marginTop: 11,
          paddingTop: 9,
          borderTop: `1px solid ${tone(9)}`,
          fontSize: 11.5,
        }}
      >
        <AnimatePresence initial={false}>
          {latest && (
            <motion.div
              key={latest.id}
              initial={{ opacity: 0, y: reduceMotion ? 0 : 5 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: reduceMotion ? 0 : -5 }}
              transition={{ duration: 0.24, ease: "easeOut" }}
              style={{ position: "absolute", left: 0, top: 9, opacity: 0.65 }}
            >
              <strong style={{ fontWeight: 600 }}>{latest.name}</strong> joined the
              group
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

/** A number that rolls to its next value in a fixed slot: the old figure
 *  leaves upward, the new one arrives from below, both at one type size.
 *  A tween, never a spring — numerals must not overshoot. */
function CountRoll({
  value,
  seconds,
  travel,
}: {
  value: number;
  seconds: number;
  travel: boolean;
}) {
  return (
    <span
      style={{
        position: "relative",
        display: "inline-block",
        width: `${String(value).length * 0.62}em`,
        height: 15,
        overflow: "hidden",
        fontVariantNumeric: "tabular-nums",
      }}
    >
      <AnimatePresence initial={false}>
        <motion.span
          key={value}
          initial={{ y: travel ? "100%" : 0, opacity: 0 }}
          animate={{ y: "0%", opacity: 1 }}
          exit={{ y: travel ? "-100%" : 0, opacity: 0 }}
          transition={{ duration: seconds, ease: [0.32, 0.72, 0, 1] }}
          style={{
            position: "absolute",
            inset: 0,
            lineHeight: "15px",
            textAlign: "left",
          }}
        >
          {value}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

About this pattern

Three movements, one event: the new avatar falls into the front of the stack, the discs already there shuffle aside to let it in, and the member total rolls over. Because they share a spring and start together, they read as a single arrival rather than as three widgets updating. The stack shifts with position-only layout animation, so the monograms travel without ever being stretched, and the number rolls on a tween — numerals that overshoot and settle back look like a fault, and text is not allowed to bounce here. The status line underneath lives in a fixed-height slot so naming who joined never grows the header and pushes the conversation down.

Member joins a group chatParticipant enters a callCollaborator opens a shared docChannel roster update

Where it shows up

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

  • Dana Whitfield
    Priya Raman
    Ops standup
    Marcus Bell
    Design sync
    Nils Bergström
    Dana Whitfieldonline
    Morning — did the venue confirm?
    They did, contract came back signed.10:14
    Are we still on for Thursday?
    Yes — booked the room for 2pm.10:14
    Perfect. I'll bring the printouts.
    See you then.10:14
    Message
    Chat thread

    The group header updates its participant count as people are added.

Related patterns