All patterns

Invite Team Send

Each address leaves the compose box and travels into a waiting seat, then marks itself sent.

onboardingenergeticfriendlyinteraction · finite · intermediate · ~0.7s
Interactive · click to play
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.

311 lines · react + motion only
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.

Team invitation stepOnboarding flowShare dialogRecipient picker

Where it shows up

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

  • 10:15
    Set up your workspaceStep 2 of 4
    What should we call it?
    Ridgeline
    Who else is joining?
    3 invited
    Next
    Onboarding flow

    Pending recipients resolve into confirmed members with a per-row status.

Related patterns