Team Seat Join
A teammate joins the avatar stack while the seat count rolls and the usage bar grows.
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, useState, type CSSProperties } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Team Seat Join
*
* A teammate accepting an invitation, seen from the workspace side. The
* avatar joins the stack, the overflow chip slides over to make room,
* and the seat count rolls to its new number in the same beat — one
* event told by three elements moving together.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Neutrals mix from the inherited text color, so the card reads correctly
* on a light page and on a dark one. Avatars are initials on a coloured
* disc, so there is no asset to load.
* Works with zero props; tune via `variant`, `joiner`, `seatLimit`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type TeamSeatJoinProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Name of the person joining. */
joiner?: string;
/** Their initials on the disc. */
joinerInitials?: string;
/** Seats in the plan. */
seatLimit?: number;
/** Accent for the joining disc and the usage bar. */
accent?: string;
/** Fires once the seat count has landed. */
onJoined?: () => void;
};
type VariantConfig = {
/** Seconds before the teammate arrives. */
lead: number;
/** How far the disc travels in, in px. */
slide: number;
/** Height of the digit roll, in px. */
roll: number;
disc: { type: "spring"; stiffness: number; damping: number };
shift: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the disc carries initials and the counter carries a
// digit, so both translate and neither scales or rebounds. Springs sit
// above a 0.8 damping ratio; variants change pace and travel only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A quiet increment. For admin screens where seats change all day.
subtle: {
lead: 0.4,
slide: 12,
roll: 14,
disc: { type: "spring", stiffness: 560, damping: 44 },
shift: { type: "spring", stiffness: 600, damping: 46 },
},
// The all-purpose setting: the arrival is worth a glance.
default: {
lead: 0.6,
slide: 22,
roll: 16,
disc: { type: "spring", stiffness: 420, damping: 37 },
shift: { type: "spring", stiffness: 480, damping: 40 },
},
// A longer entry, for a dashboard where a new teammate is an event.
playful: {
lead: 0.8,
slide: 32,
roll: 18,
disc: { type: "spring", stiffness: 340, damping: 32 },
shift: { type: "spring", stiffness: 400, damping: 36 },
},
};
/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SEATED = [
{ initials: "PR", name: "Priya Raman" },
{ initials: "LO", name: "Luca Ortiz" },
{ initials: "AK", name: "Amara Keita" },
{ initials: "TS", name: "Tomas Silva" },
];
export default function TeamSeatJoin({
variant = "default",
joiner = "Dana Whitfield",
joinerInitials = "DW",
seatLimit = 10,
accent = "#5B5BD6",
onJoined,
}: TeamSeatJoinProps) {
const [joined, setJoined] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const used = SEATED.length + 3 + (joined ? 1 : 0);
useEffect(() => {
const timer = setTimeout(() => setJoined(true), cfg.lead * 1000);
return () => clearTimeout(timer);
}, [cfg.lead]);
useEffect(() => {
if (joined) onJoined?.();
}, [joined, onJoined]);
return (
<div
style={{
width: 320,
padding: 18,
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 30,
height: 30,
borderRadius: 9,
background: tone(12),
fontSize: 11.5,
fontWeight: 700,
}}
>
NW
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 650 }}>Northwind Studio</div>
<div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>Team plan</div>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", marginTop: 16, height: 36 }}>
{SEATED.map((person, index) => (
<Disc
key={person.initials}
initials={person.initials}
style={{ marginLeft: index === 0 ? 0 : -9, zIndex: index }}
/>
))}
{/* The joining disc travels in from the right on its own spring;
everything after it slides over on a second, slightly tighter
one, so the stack reads as making room rather than reflowing. */}
<AnimatePresence initial={false}>
{joined && (
<motion.div
key="joiner"
initial={
reduceMotion
? { opacity: 0, width: 0 }
: { opacity: 0, x: cfg.slide, width: 0 }
}
animate={{ opacity: 1, x: 0, width: 27 }}
transition={
reduceMotion
? { duration: 0.16, ease: "easeOut" }
: {
x: cfg.disc,
width: { duration: 0.22, ease: "easeOut" },
opacity: { duration: 0.2, ease: "easeOut" },
}
}
style={{ zIndex: SEATED.length, marginLeft: -9 }}
>
<Disc initials={joinerInitials} accent={accent} />
</motion.div>
)}
</AnimatePresence>
<motion.span
layout="position"
transition={reduceMotion ? { duration: 0 } : cfg.shift}
style={{
display: "grid",
placeItems: "center",
height: 28,
padding: "0 9px",
marginLeft: 6,
borderRadius: 999,
background: tone(8),
border: `1px solid ${tone(10)}`,
fontSize: 11.5,
fontWeight: 600,
opacity: 0.75,
}}
>
+3
</motion.span>
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
marginTop: 16,
}}
>
<span style={{ display: "inline-flex", alignItems: "baseline", fontSize: 12.5 }}>
{/* The digit rolls in a fixed box at a constant size: the count
changes without the line reflowing and without text scaling. */}
<RollingNumber value={used} height={cfg.roll} reduceMotion={Boolean(reduceMotion)} />
<span style={{ opacity: 0.55, marginLeft: 4 }}>of {seatLimit} seats used</span>
</span>
<span style={{ fontSize: 11.5, opacity: 0.45 }}>{seatLimit - used} left</span>
</div>
<div
style={{
height: 5,
marginTop: 8,
borderRadius: 999,
background: tone(10),
overflow: "hidden",
}}
>
<motion.div
initial={{ scaleX: (used - (joined ? 1 : 0)) / seatLimit }}
animate={{ scaleX: used / seatLimit }}
transition={
reduceMotion ? { duration: 0.15 } : { duration: 0.42, ease: "easeOut" }
}
style={{
height: "100%",
borderRadius: 999,
background: accent,
transformOrigin: "left center",
}}
/>
</div>
<div style={{ position: "relative", height: 18, marginTop: 12 }}>
<AnimatePresence initial={false}>
{joined && (
<motion.div
key="caption"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={
reduceMotion
? { duration: 0.18, ease: "easeOut" }
: { ...cfg.disc, delay: 0.1 }
}
style={{
position: "absolute",
inset: 0,
fontSize: 12,
opacity: 0.6,
}}
>
{joiner} accepted the invitation
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
function Disc({
initials,
accent,
style,
}: {
initials: string;
accent?: string;
style?: CSSProperties;
}) {
return (
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
flexShrink: 0,
borderRadius: 999,
background: accent ?? "color-mix(in srgb, currentColor 13%, transparent)",
color: accent ? "#FFFFFF" : "inherit",
border: "2px solid Canvas",
fontSize: 10.5,
fontWeight: 700,
letterSpacing: 0.2,
...style,
}}
>
{initials}
</span>
);
}
function RollingNumber({
value,
height,
reduceMotion,
}: {
value: number;
height: number;
reduceMotion: boolean;
}) {
return (
<span
style={{
position: "relative",
display: "inline-block",
width: 12,
height,
overflow: "hidden",
verticalAlign: "bottom",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={value}
initial={reduceMotion ? { opacity: 0 } : { y: height, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { y: -height, opacity: 0 }}
transition={
reduceMotion
? { duration: 0.14 }
: { type: "spring", stiffness: 460, damping: 40 }
}
style={{
position: "absolute",
inset: 0,
display: "block",
fontWeight: 700,
lineHeight: `${height}px`,
fontVariantNumeric: "tabular-nums",
}}
>
{value}
</motion.span>
</AnimatePresence>
</span>
);
}About this pattern
An invitation being accepted, seen from the workspace side. Three elements move on the same beat so it reads as one event rather than three updates: the new disc travels in from the right, the overflow chip slides over to make room on a slightly tighter spring, and the seat number rolls to its new value. The disc carries initials and the counter carries a digit, so both translate and neither scales — the digit rolls inside a fixed box at a constant size, which keeps the line from reflowing as the number changes. Avatars are initials on a coloured disc throughout, so there is nothing to fetch and nothing to fail.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Team members
A stack of faces that makes room as someone new arrives.
Related patterns
- Invite Code AcceptA valid invite code turns the field into the workspace tile it lets you into.
- Signup Progress MeterA slim meter advances as each account requirement is satisfied, and every row marks itself as it passes.
- Group JoinA joining member's avatar drops into the stack, the others make room, and the count rolls by one.