All patterns

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.

onboardingpremiumfriendlyinteraction · finite · intermediate · ~0.3s
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.

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

Workspace creationOnboarding flowAccount setupRename dialog

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

    The workspace URL is generated live beneath the name field during creation.

Related patterns