All patterns

Team Seat Join

A teammate joins the avatar stack while the seat count rolls and the usage bar grows.

authenticationfriendlyenergeticautomatic · finite · intermediate · ~1.1s
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.

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

Teammate accepts an invitationSeat usage on a billing pageWorkspace member count updateCollaborator presence stack

Where it shows up

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

  • Ridgeline
    Members
    General
    Billing
    Security
    Integrations
    MembersNew
    Nils Bergströmnils@ridgeline.coAdmin
    Priya Ramanpriya@ridgeline.coMember
    Marcus Bellmarcus@ridgeline.coMember
    Dana Whitfielddana@ridgeline.coViewer
    Team members

    A stack of faces that makes room as someone new arrives.

Related patterns