Name Your Workspace
Typing a name rolls the monogram to its new initial and slides a fresh address under the header it will appear in.
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 { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Name Your Workspace
*
* The name field and the header it will produce, side by side. Typing
* updates the preview live: the monogram rolls to the new initial, the
* address line swaps under it, and an availability pill resolves once
* the keystrokes stop.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `suggestions`, `domain`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type WorkspaceNameSetProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Pre-filled name. Empty by default so the placeholder state shows. */
defaultName?: string;
/** One-tap names offered under the field. */
suggestions?: string[];
/** Host the generated address is shown against. */
domain?: string;
/** Placeholder shown in the preview before anything is typed. */
placeholderName?: string;
/** Monogram, pill and focus color. */
accent?: string;
/** Fires on every change of the committed name. */
onNameChange?: (name: string) => void;
};
type VariantConfig = {
/** How far the monogram letter travels as it rolls, in px. */
roll: number;
/** Seconds per character when a suggestion types itself in. */
keySeconds: number;
/** Idle time before the address is treated as settled, in seconds. */
settleSeconds: number;
swap: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the preview is text, so nothing here scales. The
// monogram rolls vertically at a constant size, the address crossfades,
// and every spring sits at or above a 0.8 damping ratio — a header that
// springs on each keystroke turns typing into a fairground.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Crossfades only, quick settle. For a field inside a long form.
subtle: {
roll: 5,
keySeconds: 0.028,
settleSeconds: 0.45,
swap: { type: "spring", stiffness: 520, damping: 46 },
},
// The monogram rolls, the address slides. The all-purpose setting.
default: {
roll: 9,
keySeconds: 0.038,
settleSeconds: 0.6,
swap: { type: "spring", stiffness: 420, damping: 38 },
},
// A longer roll and a slower typist, for a full-screen setup step.
playful: {
roll: 13,
keySeconds: 0.05,
settleSeconds: 0.72,
swap: { type: "spring", stiffness: 340, damping: 32 },
},
};
/** 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 SUGGESTIONS = ["Northwind Studio", "Lumen Labs", "Atlas Group"];
const slugify = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
export default function WorkspaceNameSet({
variant = "default",
defaultName = "",
suggestions = SUGGESTIONS,
domain = "northwind.app",
placeholderName = "Untitled workspace",
accent = "#5B5BD6",
onNameChange,
}: WorkspaceNameSetProps) {
const [name, setName] = useState(defaultName);
// When a suggestion is tapped the name types itself in, so the live
// preview has something to be live about without a real keyboard.
const [target, setTarget] = useState<string | null>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const trimmed = name.trim();
const slug = slugify(trimmed) || "your-team";
const initial = (trimmed[0] ?? "").toUpperCase();
// Ghost typing: one character per tick until the name matches the
// tapped suggestion. Driven by the (target, name) pair rather than an
// index, so a real keystroke mid-run simply takes over. The run retires
// the moment the name matches — a same-render adjustment, so the effect
// is left owning only the tick.
if (target !== null && name === target) setTarget(null);
useEffect(() => {
if (target === null || name === target) return;
const timer = setTimeout(
() => setName(target.slice(0, name.length + 1)),
cfg.keySeconds * 1000
);
return () => clearTimeout(timer);
}, [target, name, cfg.keySeconds]);
// The address is only claimed once typing stops — a pill that flips to
// "available" mid-word is answering a question nobody finished asking.
// Each keystroke un-settles it during render, by comparing the name held
// alongside the flag, so the effect is left owning only the timer.
const settleKey = `${trimmed}:${cfg.settleSeconds}`;
const [settle, setSettle] = useState({ key: settleKey, done: false });
if (settle.key !== settleKey) setSettle({ key: settleKey, done: false });
const settled = settle.key === settleKey && settle.done;
useEffect(() => {
if (!trimmed) return;
const timer = setTimeout(
() => setSettle({ key: settleKey, done: true }),
cfg.settleSeconds * 1000
);
return () => clearTimeout(timer);
}, [trimmed, cfg.settleSeconds, settleKey]);
const swapTransition = reduceMotion
? { duration: 0.16, ease: "easeOut" as const }
: cfg.swap;
const startTyping = (value: string) => {
onNameChange?.(value);
if (reduceMotion) {
setName(value);
return;
}
setName("");
setTarget(value);
};
return (
<div
style={{
width: 320,
padding: 18,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(6),
boxSizing: "border-box",
}}
>
<div
style={{
fontSize: 11,
fontWeight: 650,
letterSpacing: 0.4,
textTransform: "uppercase",
opacity: 0.4,
marginBottom: 8,
}}
>
Preview
</div>
{/* The header the name will live in, rebuilt on every keystroke. */}
<div
style={{
padding: 12,
borderRadius: 14,
border: `1px solid ${tone(12)}`,
background: tone(5),
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
aria-hidden
style={{
position: "relative",
flex: "none",
width: 30,
height: 30,
borderRadius: 9,
overflow: "hidden",
background: tone(12),
}}
>
{/* The accent fill and the empty-state mark cross-fade as a
pair, so the tile never shows white text on a pale
background during the handover. */}
<motion.span
animate={{ opacity: trimmed ? 1 : 0 }}
transition={{ duration: 0.28, ease: "easeOut" }}
style={{ position: "absolute", inset: 0, background: accent }}
/>
<motion.span
animate={{ opacity: trimmed ? 0 : 0.42 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
}}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<rect
x="3.4"
y="3.4"
width="9.2"
height="9.2"
rx="2.6"
stroke="currentColor"
strokeWidth="1.4"
strokeDasharray="2.6 2.3"
/>
</svg>
</motion.span>
<motion.span
animate={{ opacity: trimmed ? 1 : 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
color: "#ffffff",
fontSize: 13,
fontWeight: 700,
}}
>
{/* One glyph replaces another by travelling, never by
growing: the letter keeps a constant size throughout. */}
<AnimatePresence initial={false}>
<motion.span
key={initial}
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.roll }
}
animate={{ opacity: 1, y: 0 }}
exit={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: -cfg.roll }
}
transition={swapTransition}
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
lineHeight: 1,
}}
>
{initial}
</motion.span>
</AnimatePresence>
</motion.span>
</span>
<span style={{ minWidth: 0, flex: 1 }}>
<motion.span
// Live mirror of the field. It updates without animating —
// per-keystroke animation on a label is noise, so only the
// shift between placeholder and real name is staged.
animate={{ opacity: trimmed ? 1 : 0.42 }}
transition={{ duration: 0.24, ease: "easeOut" }}
style={{
display: "block",
fontSize: 14.5,
fontWeight: 650,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{trimmed || placeholderName}
</motion.span>
<span
style={{
display: "block",
position: "relative",
height: 15,
marginTop: 1,
fontSize: 11.5,
opacity: 0.5,
overflow: "hidden",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={slug}
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.roll }
}
animate={{ opacity: 1, y: 0 }}
exit={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: -cfg.roll }
}
transition={swapTransition}
style={{
position: "absolute",
left: 0,
top: 0,
whiteSpace: "nowrap",
}}
>
{`${domain}/${slug}`}
</motion.span>
</AnimatePresence>
</span>
</span>
</div>
<div
aria-hidden
style={{
display: "flex",
gap: 6,
marginTop: 12,
paddingTop: 10,
borderTop: `1px solid ${tone(10)}`,
}}
>
{["Overview", "Docs", "Members"].map((chip, index) => (
<span
key={chip}
style={{
padding: "4px 9px",
fontSize: 11,
fontWeight: 600,
borderRadius: 7,
opacity: index === 0 ? 0.75 : 0.4,
background: index === 0 ? tone(10) : "transparent",
}}
>
{chip}
</span>
))}
</div>
</div>
<label
style={{
display: "block",
marginTop: 16,
fontSize: 12,
fontWeight: 600,
opacity: 0.6,
}}
>
Workspace name
<span style={{ display: "flex", gap: 8, marginTop: 6 }}>
<input
value={name}
onChange={(event) => {
setTarget(null);
setName(event.target.value);
onNameChange?.(event.target.value);
}}
placeholder="Type a name"
style={{
flex: 1,
minWidth: 0,
padding: "9px 11px",
fontSize: 13.5,
fontFamily: "inherit",
fontWeight: 500,
color: "inherit",
background: tone(8),
border: `1px solid ${tone(14)}`,
borderRadius: 9,
outline: "none",
boxSizing: "border-box",
}}
/>
<span
aria-live="polite"
style={{
// Fixed width: a pill that resizes as its label changes
// would drag the field's edge around mid-sentence.
position: "relative",
flex: "none",
width: 82,
height: 34,
borderRadius: 9,
border: `1px solid ${tone(12)}`,
background: tone(6),
overflow: "hidden",
}}
>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={!trimmed ? "idle" : settled ? "ready" : "checking"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 5,
fontSize: 11.5,
fontWeight: 600,
}}
>
{!trimmed ? (
<span style={{ opacity: 0.4 }}>Address</span>
) : settled ? (
<>
<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>
<span style={{ opacity: 0.75 }}>Free</span>
</>
) : (
<span style={{ opacity: 0.45 }}>Checking</span>
)}
</motion.span>
</AnimatePresence>
</span>
</span>
</label>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginTop: 10,
}}
>
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => startTyping(suggestion)}
style={{
padding: "5px 10px",
fontSize: 11.5,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: tone(7),
border: `1px solid ${tone(12)}`,
borderRadius: 8,
cursor: "pointer",
}}
>
{suggestion}
</button>
))}
</div>
</div>
);
}About this pattern
The naming step, answered before it is submitted. A miniature of the real header sits above the field: type, and the monogram rolls to the new initial, the generated address slides in under it, and the tile fills with the accent the moment there is something to name. An availability pill resolves only once the keystrokes stop, because a verdict that flips mid-word is answering a question nobody finished asking. Everything in the preview is text, so nothing scales — the letter travels vertically at a constant size and the address crossfades in a fixed-width slot, which keeps the header's edges still while its contents change.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
The workspace URL is generated live beneath the name field during creation.
Related patterns
- Persona SelectPicking a role lifts that card while the others step back, and a line below says what the answer changed.
- Tour Step HopThe tour tooltip travels to the next control instead of vanishing and popping up somewhere else.
- Tutorial Card PlayThe tutorial thumbnail grows into a player, and the controls only arrive once the frame has stopped moving.