Group Join
A joining member's avatar drops into the stack, the others make room, and the count rolls by one.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
The group header updates its participant count as people are added.
Related patterns
- Avatar Stack OverflowParticipants slide into the overlapping stack and the overflow count takes over.
- Like ButtonThe heart takes on its fill and settles once — no particle spray, no confetti.
- Live Viewer CountOne ring per arrival, digits rolling to the new total, and the size of the jump floating off above it.