All patterns

Invite Code Accept

A valid invite code turns the field into the workspace tile it lets you into.

authenticationenergeticfriendlyinteraction · finite · intermediate · ~0.8s
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.

336 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Invite Code Accept
 *
 * A valid invite code stops being a string and becomes the place it lets
 * you into: the field grows into a workspace tile in one move, so the
 * thing the user typed and the thing they get are the same object.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Neutrals mix from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `code`, `workspace`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type InviteCodeAcceptProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Prefilled invite code — a sample string, not a real credential. */
  code?: string;
  /** Workspace the code resolves to. */
  workspace?: string;
  /** Line under the workspace name. */
  workspaceDetail?: string;
  /** Two letters on the workspace disc. */
  initials?: string;
  /** Primary color for the disc and the confirmation. */
  accent?: string;
  /** Fires once the workspace tile has settled. */
  onAccepted?: () => void;
};

type VariantConfig = {
  /** How long the container takes to grow into the tile, in seconds. */
  grow: number;
  /** Travel of the incoming tile contents, in px. */
  rise: number;
  /** How long the code is checked before it resolves, in ms. */
  checkMs: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the tile carries a workspace name, so it never scales or
// bounces into place — it rises and lands. Every spring here sits above a
// 0.8 damping ratio; variants change pace and travel, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a transition. For enterprise onboarding where joining is
  // paperwork, not a celebration.
  subtle: {
    grow: 0.2,
    rise: 5,
    checkMs: 420,
    spring: { type: "spring", stiffness: 560, damping: 44 },
  },
  // Quick and warm — the field visibly becomes the workspace.
  default: {
    grow: 0.26,
    rise: 9,
    checkMs: 560,
    spring: { type: "spring", stiffness: 440, damping: 38 },
  },
  // More travel and a longer beat on the check, so the resolve lands as
  // a small arrival rather than a state change.
  playful: {
    grow: 0.32,
    rise: 14,
    checkMs: 700,
    spring: { type: "spring", stiffness: 360, damping: 32 },
  },
};

/** 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 FIELD_HEIGHT = 48;
const TILE_HEIGHT = 84;

export default function InviteCodeAccept({
  variant = "default",
  code = "MERIDIAN-7Q4K",
  workspace = "Northwind Studio",
  workspaceDetail = "12 members · you join as Editor",
  initials = "NW",
  accent = "#5B5BD6",
  onAccepted,
}: InviteCodeAcceptProps) {
  const [stage, setStage] = useState<"idle" | "checking" | "joined">("idle");
  const [value, setValue] = useState(code);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  useEffect(() => () => clearTimeout(timer.current), []);

  const submit = () => {
    if (stage !== "idle") return;
    setStage("checking");
    timer.current = setTimeout(() => setStage("joined"), cfg.checkMs);
  };

  const joined = stage === "joined";

  // Reduced motion keeps the morph as information — the field is still
  // replaced by the workspace it resolved to — and drops the growth and
  // the travel that carry it.
  const swap = reduceMotion
    ? {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0 },
        transition: { duration: 0.14, ease: "easeOut" as const },
      }
    : {
        initial: { opacity: 0, y: cfg.rise },
        animate: { opacity: 1, y: 0 },
        exit: { opacity: 0, y: -cfg.rise * 0.5 },
        transition: {
          ...cfg.spring,
          opacity: { duration: 0.18, ease: "easeOut" as const },
        },
      };

  return (
    <div
      style={{
        width: 320,
        padding: 18,
        borderRadius: 16,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
      }}
    >
      <div style={{ fontSize: 11, fontWeight: 650, letterSpacing: 0.5, opacity: 0.5 }}>
        INVITATION
      </div>
      <div style={{ fontSize: 16, fontWeight: 650, marginTop: 6 }}>
        Join a workspace
      </div>
      <div style={{ fontSize: 12.5, opacity: 0.55, marginTop: 4, lineHeight: 1.5 }}>
        Enter the code from your invitation email
      </div>

      {/* One container owns both states. It changes height — a real size
          change, so it tweens on an ease rather than springing — while
          the contents crossfade inside it. Absolute children mean the
          outgoing state cannot shove the incoming one around. */}
      <motion.div
        animate={{ height: joined ? TILE_HEIGHT : FIELD_HEIGHT }}
        transition={
          reduceMotion ? { duration: 0 } : { duration: cfg.grow, ease: "easeOut" }
        }
        style={{
          position: "relative",
          marginTop: 14,
          height: FIELD_HEIGHT,
          borderRadius: 12,
          border: `1px solid ${joined ? tone(8) : tone(14)}`,
          background: joined ? tone(8) : tone(4),
          overflow: "hidden",
          transition: "background-color 220ms ease, border-color 220ms ease",
        }}
      >
        <AnimatePresence mode="wait" initial={false}>
          {joined ? (
            <motion.div
              key="tile"
              {...swap}
              onAnimationComplete={() => onAccepted?.()}
              style={{
                position: "absolute",
                inset: 0,
                display: "flex",
                alignItems: "center",
                gap: 12,
                padding: "0 14px",
              }}
            >
              {/* Workspace mark: initials on a colored disc, never an
                  image — the component ships with no asset. */}
              <span
                aria-hidden
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: 40,
                  height: 40,
                  flexShrink: 0,
                  borderRadius: 12,
                  background: accent,
                  color: "#FFFFFF",
                  fontSize: 14,
                  fontWeight: 700,
                  letterSpacing: 0.3,
                }}
              >
                {initials}
              </span>
              <div style={{ minWidth: 0, flex: 1 }}>
                <div style={{ fontSize: 13.5, fontWeight: 650 }}>{workspace}</div>
                <div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 3 }}>
                  {workspaceDetail}
                </div>
              </div>
              <span
                aria-hidden
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: 22,
                  height: 22,
                  flexShrink: 0,
                  borderRadius: 999,
                  background: accent,
                  color: "#FFFFFF",
                }}
              >
                <svg width="12" height="12" viewBox="0 0 20 20" fill="none">
                  <motion.path
                    d="M5.5 10.4l3 3 6-6.4"
                    stroke="currentColor"
                    strokeWidth="2.1"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    initial={{ pathLength: reduceMotion ? 1 : 0 }}
                    animate={{ pathLength: 1 }}
                    transition={
                      reduceMotion
                        ? { duration: 0 }
                        : { duration: 0.26, delay: 0.1, ease: "easeOut" }
                    }
                  />
                </svg>
              </span>
            </motion.div>
          ) : (
            <motion.div
              key="field"
              {...swap}
              style={{
                position: "absolute",
                inset: 0,
                display: "flex",
                alignItems: "center",
                gap: 8,
                padding: "0 6px 0 12px",
              }}
            >
              <input
                aria-label="Invite code"
                value={value}
                onChange={(event) => setValue(event.target.value.toUpperCase())}
                spellCheck={false}
                style={{
                  flex: 1,
                  minWidth: 0,
                  border: "none",
                  outline: "none",
                  background: "transparent",
                  color: "inherit",
                  fontSize: 13,
                  fontWeight: 600,
                  letterSpacing: 1,
                  fontFamily:
                    "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
                }}
              />
              <button
                type="button"
                onClick={submit}
                disabled={stage === "checking"}
                style={{
                  flexShrink: 0,
                  display: "flex",
                  alignItems: "center",
                  gap: 7,
                  padding: "8px 13px",
                  fontSize: 12.5,
                  fontWeight: 600,
                  fontFamily: "inherit",
                  borderRadius: 9,
                  border: "none",
                  background: accent,
                  color: "#FFFFFF",
                  cursor: stage === "checking" ? "default" : "pointer",
                }}
              >
                {stage === "checking" && (
                  // A rotating arc, not a label change: the button keeps
                  // its width so nothing reflows while the code is checked.
                  <motion.span
                    aria-hidden
                    animate={reduceMotion ? { rotate: 0 } : { rotate: 360 }}
                    transition={
                      reduceMotion
                        ? { duration: 0 }
                        : { duration: 0.7, repeat: Infinity, ease: "linear" }
                    }
                    style={{ display: "grid", placeItems: "center" }}
                  >
                    <svg width="12" height="12" viewBox="0 0 20 20" fill="none">
                      <circle
                        cx="10"
                        cy="10"
                        r="7"
                        stroke="currentColor"
                        strokeOpacity="0.35"
                        strokeWidth="2.2"
                      />
                      <path
                        d="M17 10a7 7 0 0 0-7-7"
                        stroke="currentColor"
                        strokeWidth="2.2"
                        strokeLinecap="round"
                      />
                    </svg>
                  </motion.span>
                )}
                {stage === "checking" ? "Checking" : "Join"}
              </button>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>

      <div aria-live="polite" style={{ fontSize: 11.5, opacity: 0.5, marginTop: 10 }}>
        {joined ? "Invitation accepted" : "Codes expire seven days after they are sent"}
      </div>
    </div>
  );
}

About this pattern

An invite code is an abstraction until it resolves into a place. Rather than clearing the field and rendering a result somewhere else, the field itself grows into the workspace tile: same container, same position, contents crossfading inside it. The height change tweens on an ease because it is a real size change, while the tile contents rise on a spring that lands without a rebound — the workspace name is text, and text that bounces reads as cheap. A short checking state with a rotating arc holds the button width steady so nothing reflows while the code is verified, and the confirmation check draws last.

Accepting a workspace invitationRedeeming a join codeTeam onboarding entry screenBeta access code entry

Where it shows up

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

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

    A code screen that resolves into the named workspace before you enter it.

Related patterns