Invite Team Send
Each address leaves the compose box and travels into a waiting seat, then marks itself sent.
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 } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Invite Team Send
*
* Sending invitations moves the chips rather than replacing them: each
* address leaves the compose box and travels into a waiting seat below,
* one after the next, and only then is it marked as sent.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `emails`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type InviteTeamSendProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Addresses in the compose box. The embedded sample is used when omitted. */
emails?: string[];
/** Send button label. */
sendLabel?: string;
/** Button, monogram and confirmation color. */
accent?: string;
/** Fires with each address as it lands in the invited list. */
onInvite?: (email: string) => void;
/** Fires once every invitation has been sent. */
onComplete?: () => void;
};
type VariantConfig = {
/** Gap between one address leaving and the next, in seconds. */
stagger: number;
travel: { type: "spring"; stiffness: number; damping: number };
/** Pause between a chip landing and its confirmation appearing. */
confirmDelay: number;
};
// Quality rule: a chip carries an email address, so it may travel but it
// may never scale — a name that grows on the way down is unreadable
// exactly when the eye is following it. Every spring sits at or above a
// 0.8 damping ratio, so each chip lands once and stays.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Nearly simultaneous, quick springs. For a long list of invitees.
subtle: {
stagger: 0.07,
travel: { type: "spring", stiffness: 520, damping: 46 },
confirmDelay: 0.1,
},
// Clear one-two-three. The all-purpose setting.
default: {
stagger: 0.13,
travel: { type: "spring", stiffness: 400, damping: 36 },
confirmDelay: 0.16,
},
// A longer beat between departures, for a three-person founding team.
playful: {
stagger: 0.2,
travel: { type: "spring", stiffness: 340, damping: 32 },
confirmDelay: 0.22,
},
};
/** Neutral surfaces are mixed from the inherited text color, so the card
* reads correctly on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE_EMAILS = [
"riley@northwind.co",
"amara@northwind.co",
"devon@northwind.co",
];
/** Seats are reserved up front so the card never changes height while
* chips are in flight — a frame that grows under a moving element makes
* the element look like it is being pushed. */
const SEAT_HEIGHT = 34;
export default function InviteTeamSend({
variant = "default",
emails = SAMPLE_EMAILS,
sendLabel = "Send invitations",
accent = "#5B5BD6",
onInvite,
onComplete,
}: InviteTeamSendProps) {
const [sending, setSending] = useState(false);
const [sentCount, setSentCount] = useState(0);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const stagger = reduceMotion ? cfg.stagger * 0.5 : cfg.stagger;
const done = sentCount >= emails.length;
// One departure per tick. The timer is the stagger — no orchestration
// library, and a mid-flight unmount cancels cleanly. The run closes
// from inside the tick that sends the last invitation, so the effect
// body only ever schedules; it never sets state synchronously.
useEffect(() => {
if (!sending || sentCount >= emails.length) return;
const timer = setTimeout(() => {
onInvite?.(emails[sentCount]);
setSentCount(sentCount + 1);
if (sentCount + 1 === emails.length) {
setSending(false);
onComplete?.();
}
}, stagger * 1000);
return () => clearTimeout(timer);
}, [sending, sentCount, emails, stagger, onInvite, onComplete]);
const chip = (email: string, landed: boolean) => (
<motion.span
key={email}
// The same element in two places: Motion measures both and moves
// it. Position only — the chip's size is identical in the compose
// box and in the seat, so nothing has to be scale-corrected.
layoutId={reduceMotion ? undefined : `invite-${email}`}
layout={reduceMotion ? false : "position"}
transition={reduceMotion ? { duration: 0.14 } : cfg.travel}
style={{
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "5px 10px 5px 5px",
borderRadius: 999,
border: `1px solid ${landed ? tone(10) : tone(16)}`,
background: tone(landed ? 5 : 9),
fontSize: 12,
lineHeight: 1.2,
whiteSpace: "nowrap",
}}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 18,
height: 18,
borderRadius: 999,
fontSize: 9.5,
fontWeight: 700,
color: "#ffffff",
background: accent,
opacity: landed ? 1 : 0.75,
}}
>
{email[0].toUpperCase()}
</span>
<span style={{ opacity: landed ? 0.7 : 0.9 }}>{email}</span>
</motion.span>
);
return (
<div
style={{
width: 320,
padding: 18,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(6),
boxSizing: "border-box",
}}
>
<div style={{ fontSize: 15.5, fontWeight: 650 }}>Invite your team</div>
<p style={{ margin: "5px 0 12px", fontSize: 12.5, opacity: 0.55 }}>
They land in the same projects you are working in today.
</p>
<div
style={{
display: "flex",
flexWrap: "wrap",
alignContent: "flex-start",
gap: 6,
height: 74,
padding: 10,
borderRadius: 12,
border: `1px dashed ${tone(16)}`,
background: tone(4),
boxSizing: "border-box",
overflow: "hidden",
}}
>
{emails.slice(sentCount).map((email) => chip(email, false))}
{sentCount === emails.length && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 0.4 }}
transition={{ duration: 0.3, ease: "easeOut", delay: 0.1 }}
style={{ alignSelf: "center", fontSize: 12 }}
>
Everyone on the list has been invited.
</motion.span>
)}
</div>
<button
type="button"
onClick={() => setSending(true)}
disabled={sending || done}
style={{
width: "100%",
marginTop: 10,
padding: "9px 14px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
color: done ? "inherit" : "#ffffff",
background: done ? tone(8) : accent,
border: done ? `1px solid ${tone(14)}` : "none",
borderRadius: 9,
cursor: sending || done ? "default" : "pointer",
opacity: sending ? 0.7 : 1,
}}
>
{done ? `${emails.length} invitations sent` : sendLabel}
</button>
<div
style={{
display: "flex",
justifyContent: "space-between",
margin: "14px 0 6px",
fontSize: 11,
fontWeight: 650,
letterSpacing: 0.4,
textTransform: "uppercase",
opacity: 0.4,
}}
>
<span>Invited</span>
<span>{`${sentCount} of ${emails.length}`}</span>
</div>
<div
style={{
position: "relative",
height: emails.length * SEAT_HEIGHT,
}}
>
{emails.map((email, index) => (
<div
key={email}
style={{
position: "absolute",
left: 0,
right: 0,
top: index * SEAT_HEIGHT,
height: SEAT_HEIGHT - 6,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
paddingRight: 2,
}}
>
{index < sentCount ? (
chip(email, true)
) : (
// The empty seat: visible from the start so the chips have
// somewhere to be going.
<span
aria-hidden
style={{
flex: 1,
height: SEAT_HEIGHT - 12,
borderRadius: 999,
border: `1px dashed ${tone(11)}`,
}}
/>
)}
{index < sentCount && (
<motion.span
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.24,
delay: cfg.confirmDelay,
ease: "easeOut",
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
flex: "none",
fontSize: 11,
fontWeight: 600,
opacity: 0.6,
}}
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
<path
d="M3.5 8.4 6.6 11.5 12.5 5"
stroke={accent}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Sent
</motion.span>
)}
</div>
))}
</div>
</div>
);
}About this pattern
Sending invitations without redrawing them. The chips in the compose box are the same elements that end up in the invited list — Motion measures both positions and moves each one, so the eye can follow a specific address from where it was typed to where it landed. Departures are staggered by a beat, and the confirmation only appears once a chip has settled, which makes the send read as three things happening rather than one blur. The seats are reserved from the start and the chips never change size, so the card holds its height and the addresses stay readable the whole way down.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
Pending recipients resolve into confirmed members with a per-row status.
Related patterns
- Seed First ProjectThe blank panel steps aside and a starter project builds itself in its place, task by task.
- Goal PickerChosen goal tiles paint themselves and take a mark, and the continue action rises the moment the first one is picked.
- Sample Data PopulateExample figures sweep into an empty report so a new account can see what the product does before it has data of its own.