Invite Code Accept
A valid invite code turns the field into the workspace tile it lets you into.
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, 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
A code screen that resolves into the named workspace before you enter it.
Related patterns
- Team Seat JoinA teammate joins the avatar stack while the seat count rolls and the usage bar grows.
- Invite Team SendEach address leaves the compose box and travels into a waiting seat, then marks itself sent.
- Signup Progress MeterA slim meter advances as each account requirement is satisfied, and every row marks itself as it passes.