Web InnoventixFreeCode

Wizard Stepper

Original · free

wizard stepper with next/back

byWeb InnoventixReact + Tailwind
stepwizardsteppers
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/step-wizard.json
step-wizard.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Plan = "starter" | "team" | "business";
type Role = "member" | "admin";

interface WizardData {
  email: string;
  password: string;
  confirm: string;
  workspaceName: string;
  subdomain: string;
  plan: Plan;
  invites: string[];
  role: Role;
  notify: boolean;
}

interface StepDef {
  id: string;
  title: string;
  desc: string;
}

const STEPS: StepDef[] = [
  { id: "account", title: "Account", desc: "Your sign-in details" },
  { id: "workspace", title: "Workspace", desc: "Name & plan" },
  { id: "team", title: "Team", desc: "Invite people" },
  { id: "review", title: "Review", desc: "Confirm & create" },
];

const PLANS: { id: Plan; name: string; price: string; blurb: string; popular?: boolean }[] = [
  { id: "starter", name: "Starter", price: "$0", blurb: "Up to 3 members, 2 active projects." },
  { id: "team", name: "Team", price: "$12", blurb: "Unlimited projects, roles, priority support.", popular: true },
  { id: "business", name: "Business", price: "$24", blurb: "SSO, audit logs, and a 99.9% uptime SLA." },
];

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SLUG_RE = /^[a-z0-9-]{3,}$/;

const FOCUS =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-950";

function slugify(value: string): string {
  return value
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9\s-]/g, "")
    .replace(/\s+/g, "-")
    .replace(/-+/g, "-")
    .slice(0, 24);
}

function validateStep(step: number, d: WizardData): Record<string, string> {
  const e: Record<string, string> = {};
  if (step === 0) {
    if (!EMAIL_RE.test(d.email)) e.email = "Enter a valid email address.";
    if (d.password.length < 8) e.password = "Use at least 8 characters.";
    if (d.confirm !== d.password) e.confirm = "Passwords don't match.";
  } else if (step === 1) {
    if (d.workspaceName.trim().length < 2) e.workspaceName = "Give your workspace a name.";
    if (!SLUG_RE.test(d.subdomain)) e.subdomain = "3+ chars: lowercase letters, numbers, hyphens.";
  }
  return e;
}

function TextField(props: {
  id: string;
  label: string;
  value: string;
  onChange: (v: string) => void;
  type?: string;
  placeholder?: string;
  error?: string;
  hint?: string;
  autoComplete?: string;
  suffix?: string;
}) {
  const { id, label, value, onChange, type = "text", placeholder, error, hint, autoComplete, suffix } = props;
  const hintId = `${id}-hint`;
  const errId = `${id}-err`;
  const describedBy = [error ? errId : null, hint ? hintId : null].filter(Boolean).join(" ") || undefined;
  return (
    <div>
      <label htmlFor={id} className="mb-1.5 block text-sm font-medium text-neutral-800 dark:text-neutral-200">
        {label}
      </label>
      <div className="relative">
        <input
          id={id}
          type={type}
          value={value}
          placeholder={placeholder}
          autoComplete={autoComplete}
          aria-invalid={error ? true : undefined}
          aria-describedby={describedBy}
          onChange={(e) => onChange(e.target.value)}
          className={
            "w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 transition-colors dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-500 " +
            (suffix ? "pr-24 " : "") +
            (error
              ? "border-rose-400 dark:border-rose-500/70 "
              : "border-neutral-300 hover:border-neutral-400 dark:border-neutral-700 dark:hover:border-neutral-600 ") +
            FOCUS
          }
        />
        {suffix ? (
          <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3.5 text-sm text-neutral-400 dark:text-neutral-500">
            {suffix}
          </span>
        ) : null}
      </div>
      {hint && !error ? (
        <p id={hintId} className="mt-1.5 text-xs text-neutral-500 dark:text-neutral-400">
          {hint}
        </p>
      ) : null}
      {error ? (
        <p id={errId} className="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">
          <svg viewBox="0 0 20 20" width="14" height="14" fill="currentColor" aria-hidden="true">
            <path
              fillRule="evenodd"
              d="M10 18a8 8 0 100-16 8 8 0 000 16zM9 9a1 1 0 012 0v4a1 1 0 11-2 0V9zm1-4a1 1 0 100 2 1 1 0 000-2z"
              clipRule="evenodd"
            />
          </svg>
          {error}
        </p>
      ) : null}
    </div>
  );
}

export default function StepWizard() {
  const reduce = useReducedMotion();
  const uid = useId();
  const inviteInputRef = useRef<HTMLInputElement>(null);
  const mounted = useRef(false);

  const [current, setCurrent] = useState(0);
  const [direction, setDirection] = useState(1);
  const [completed, setCompleted] = useState<Set<number>>(new Set());
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [done, setDone] = useState(false);
  const [subTouched, setSubTouched] = useState(false);
  const [inviteDraft, setInviteDraft] = useState("");
  const [inviteError, setInviteError] = useState("");

  const [data, setData] = useState<WizardData>({
    email: "",
    password: "",
    confirm: "",
    workspaceName: "",
    subdomain: "",
    plan: "team",
    invites: [],
    role: "member",
    notify: true,
  });

  const isReview = current === STEPS.length - 1;

  useEffect(() => {
    mounted.current = true;
  }, []);

  const focusHeading = useCallback((node: HTMLHeadingElement | null) => {
    if (node && mounted.current) node.focus();
  }, []);

  function set<K extends keyof WizardData>(key: K, value: WizardData[K]) {
    setData((prev) => ({ ...prev, [key]: value }));
    setErrors((prev) => {
      if (!(key in prev)) return prev;
      const next = { ...prev };
      delete next[key];
      return next;
    });
  }

  function canGo(i: number): boolean {
    return i <= current || completed.has(i);
  }

  function goToStep(i: number) {
    if (i === current) return;
    setDirection(i > current ? 1 : -1);
    setCurrent(i);
  }

  function handleNext() {
    const errs = validateStep(current, data);
    if (Object.keys(errs).length > 0) {
      setErrors(errs);
      return;
    }
    setErrors({});
    setCompleted((prev) => new Set(prev).add(current));
    setDirection(1);
    setCurrent((c) => Math.min(c + 1, STEPS.length - 1));
  }

  function handleBack() {
    setDirection(-1);
    setCurrent((c) => Math.max(c - 1, 0));
  }

  function handleCreate() {
    const s0 = validateStep(0, data);
    if (Object.keys(s0).length > 0) {
      setErrors(s0);
      goToStep(0);
      return;
    }
    const s1 = validateStep(1, data);
    if (Object.keys(s1).length > 0) {
      setErrors(s1);
      goToStep(1);
      return;
    }
    setCompleted(new Set([0, 1, 2, 3]));
    setDone(true);
  }

  function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (isReview) handleCreate();
    else handleNext();
  }

  function addInvite() {
    const value = inviteDraft.trim().toLowerCase();
    if (!value) return;
    if (!EMAIL_RE.test(value)) {
      setInviteError("That doesn't look like a valid email.");
      return;
    }
    if (data.invites.includes(value)) {
      setInviteError("You've already added that person.");
      return;
    }
    set("invites", [...data.invites, value]);
    setInviteDraft("");
    setInviteError("");
    inviteInputRef.current?.focus();
  }

  function removeInvite(email: string) {
    set(
      "invites",
      data.invites.filter((x) => x !== email),
    );
  }

  function reset() {
    setDone(false);
    setCurrent(0);
    setDirection(-1);
    setCompleted(new Set());
    setErrors({});
    setSubTouched(false);
    setInviteDraft("");
    setInviteError("");
    setData({
      email: "",
      password: "",
      confirm: "",
      workspaceName: "",
      subdomain: "",
      plan: "team",
      invites: [],
      role: "member",
      notify: true,
    });
  }

  const progress = done ? 100 : (current / (STEPS.length - 1)) * 100;
  const headingId = `${uid}-heading`;

  const panelVariants = reduce
    ? {
        enter: { opacity: 1, x: 0 },
        center: { opacity: 1, x: 0 },
        exit: { opacity: 1, x: 0 },
      }
    : {
        enter: (d: number) => ({ opacity: 0, x: d > 0 ? 28 : -28 }),
        center: { opacity: 1, x: 0 },
        exit: (d: number) => ({ opacity: 0, x: d > 0 ? -28 : 28 }),
      };

  return (
    <section className="relative w-full bg-neutral-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-neutral-950">
      <style>{`
        @keyframes swz-check { from { stroke-dashoffset: 26; } to { stroke-dashoffset: 0; } }
        @keyframes swz-pop { 0% { transform: scale(.6); opacity: 0; } 60% { transform: scale(1.05); } 100% { transform: scale(1); opacity: 1; } }
        .swz-check-path { stroke-dasharray: 26; animation: swz-check .5s ease-out .15s both; }
        .swz-pop { animation: swz-pop .45s cubic-bezier(.34,1.56,.64,1) both; }
        .swz-fill { transition: width .55s cubic-bezier(.4,0,.2,1); }
        @media (prefers-reduced-motion: reduce) {
          .swz-check-path { animation: none !important; stroke-dashoffset: 0 !important; }
          .swz-pop { animation: none !important; }
          .swz-fill { transition: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-2xl">
        <div className="mb-8 text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-3 py-1 text-xs font-medium text-neutral-600 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400">
            <span className="inline-block h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Cadence · Set up your workspace
          </span>
          <h2 className="mt-4 text-2xl font-semibold tracking-tight text-neutral-900 sm:text-3xl dark:text-white">
            Get your team on the same page
          </h2>
          <p className="mx-auto mt-2 max-w-md text-sm text-neutral-600 dark:text-neutral-400">
            Four quick steps. You can jump back to any completed step to make changes before you finish.
          </p>
        </div>

        <div className="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
          {/* Stepper header */}
          <div className="border-b border-neutral-200 bg-neutral-50/60 px-4 pb-5 pt-6 sm:px-8 dark:border-neutral-800 dark:bg-neutral-900/40">
            <div className="relative">
              <div
                className="absolute left-0 right-0 top-4 mx-5 h-0.5 rounded-full bg-neutral-200 dark:bg-neutral-800"
                aria-hidden="true"
              >
                <div
                  className="swz-fill h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
                  style={{ width: `${progress}%` }}
                />
              </div>
              <ol className="relative flex items-start justify-between">
                {STEPS.map((step, i) => {
                  const isCurrent = !done && i === current;
                  const isComplete = done || completed.has(i);
                  const reachable = !done && canGo(i);
                  return (
                    <li key={step.id} className="flex min-w-0 flex-col items-center gap-2 text-center">
                      <button
                        type="button"
                        onClick={() => reachable && goToStep(i)}
                        disabled={!reachable}
                        aria-current={isCurrent ? "step" : undefined}
                        aria-label={`Step ${i + 1}: ${step.title}${
                          isComplete ? " (completed)" : isCurrent ? " (current)" : ""
                        }`}
                        className={
                          "relative z-10 flex h-8 w-8 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors " +
                          FOCUS +
                          " " +
                          (isComplete
                            ? "border-indigo-500 bg-indigo-500 text-white"
                            : isCurrent
                              ? "border-indigo-500 bg-white text-indigo-600 dark:bg-neutral-900 dark:text-indigo-400"
                              : "border-neutral-300 bg-white text-neutral-400 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-500") +
                          (reachable ? " cursor-pointer" : " cursor-default")
                        }
                      >
                        {isComplete ? (
                          <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden="true">
                            <path
                              className="swz-check-path"
                              d="M5 10.5 8.5 14 15 6.5"
                              stroke="currentColor"
                              strokeWidth="2.2"
                              strokeLinecap="round"
                              strokeLinejoin="round"
                            />
                          </svg>
                        ) : (
                          <span>{i + 1}</span>
                        )}
                      </button>
                      <span className="flex flex-col">
                        <span
                          className={
                            "text-xs font-semibold sm:text-sm " +
                            (isCurrent || isComplete
                              ? "text-neutral-900 dark:text-white"
                              : "text-neutral-500 dark:text-neutral-400")
                          }
                        >
                          {step.title}
                        </span>
                        <span className="hidden text-[11px] text-neutral-400 sm:block dark:text-neutral-500">
                          {step.desc}
                        </span>
                      </span>
                    </li>
                  );
                })}
              </ol>
            </div>
          </div>

          {/* Panel */}
          <div className="px-4 py-7 sm:px-8 sm:py-9">
            {done ? (
              <div className="flex flex-col items-center py-6 text-center">
                <div className="swz-pop mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
                  <svg viewBox="0 0 24 24" width="30" height="30" fill="none" aria-hidden="true">
                    <path
                      className="swz-check-path"
                      d="M6 12.5 10.5 17 18.5 7.5"
                      stroke="currentColor"
                      strokeWidth="2.4"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                </div>
                <h3
                  id={headingId}
                  ref={focusHeading}
                  tabIndex={-1}
                  className="text-xl font-semibold text-neutral-900 outline-none dark:text-white"
                >
                  Your workspace is live
                </h3>
                <p className="mt-2 max-w-sm text-sm text-neutral-600 dark:text-neutral-400">
                  <span className="font-medium text-neutral-900 dark:text-neutral-200">{data.workspaceName}</span> is ready
                  at <span className="font-mono text-indigo-600 dark:text-indigo-400">{data.subdomain || "your-team"}.cadence.app</span>
                  {data.invites.length > 0
                    ? `. We've emailed ${data.invites.length} invite${data.invites.length > 1 ? "s" : ""}.`
                    : "."}
                </p>
                <button
                  type="button"
                  onClick={reset}
                  className={
                    "mt-7 inline-flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800 " +
                    FOCUS
                  }
                >
                  <svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
                    <path d="M4 10a6 6 0 1 0 1.6-4.1M4 3v3h3" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                  Set up another workspace
                </button>
              </div>
            ) : (
              <form onSubmit={handleSubmit}>
                <AnimatePresence mode="wait" custom={direction} initial={false}>
                  <motion.div
                    key={current}
                    custom={direction}
                    variants={panelVariants}
                    initial="enter"
                    animate="center"
                    exit="exit"
                    transition={{ duration: reduce ? 0 : 0.28, ease: [0.4, 0, 0.2, 1] }}
                  >
                    <div role="group" aria-labelledby={headingId}>
                      <h3
                        id={headingId}
                        ref={focusHeading}
                        tabIndex={-1}
                        className="text-lg font-semibold text-neutral-900 outline-none dark:text-white"
                      >
                        {current === 0 && "Create your account"}
                        {current === 1 && "Name your workspace"}
                        {current === 2 && "Invite your teammates"}
                        {current === 3 && "Review and confirm"}
                      </h3>
                      <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
                        {current === 0 && "You'll use this to sign in and manage billing."}
                        {current === 1 && "This is what your team and clients will see."}
                        {current === 2 && "Optional — you can always invite people later."}
                        {current === 3 && "Double-check everything before we spin it up."}
                      </p>

                      <div className="mt-6 space-y-5">
                        {/* Step 1: Account */}
                        {current === 0 && (
                          <>
                            <TextField
                              id={`${uid}-email`}
                              label="Work email"
                              type="email"
                              autoComplete="email"
                              placeholder="you@company.com"
                              value={data.email}
                              onChange={(v) => set("email", v)}
                              error={errors.email}
                            />
                            <div className="grid gap-5 sm:grid-cols-2">
                              <TextField
                                id={`${uid}-password`}
                                label="Password"
                                type="password"
                                autoComplete="new-password"
                                placeholder="At least 8 characters"
                                value={data.password}
                                onChange={(v) => set("password", v)}
                                error={errors.password}
                                hint="Use a mix of letters and numbers."
                              />
                              <TextField
                                id={`${uid}-confirm`}
                                label="Confirm password"
                                type="password"
                                autoComplete="new-password"
                                placeholder="Re-enter password"
                                value={data.confirm}
                                onChange={(v) => set("confirm", v)}
                                error={errors.confirm}
                              />
                            </div>
                          </>
                        )}

                        {/* Step 2: Workspace */}
                        {current === 1 && (
                          <>
                            <TextField
                              id={`${uid}-wsname`}
                              label="Workspace name"
                              placeholder="Northwind Design Studio"
                              value={data.workspaceName}
                              onChange={(v) => {
                                set("workspaceName", v);
                                if (!subTouched) set("subdomain", slugify(v));
                              }}
                              error={errors.workspaceName}
                            />
                            <TextField
                              id={`${uid}-subdomain`}
                              label="Workspace URL"
                              placeholder="northwind"
                              suffix=".cadence.app"
                              value={data.subdomain}
                              onChange={(v) => {
                                setSubTouched(true);
                                set("subdomain", v.toLowerCase().replace(/[^a-z0-9-]/g, ""));
                              }}
                              error={errors.subdomain}
                              hint="Lowercase letters, numbers and hyphens only."
                            />
                            <fieldset>
                              <legend className="mb-2 text-sm font-medium text-neutral-800 dark:text-neutral-200">
                                Choose a plan
                              </legend>
                              <div className="grid gap-3 sm:grid-cols-3">
                                {PLANS.map((plan) => {
                                  const selected = data.plan === plan.id;
                                  return (
                                    <label
                                      key={plan.id}
                                      className={
                                        "relative flex cursor-pointer flex-col rounded-xl border p-4 transition-colors " +
                                        (selected
                                          ? "border-indigo-500 bg-indigo-50/70 ring-1 ring-indigo-500 dark:border-indigo-400 dark:bg-indigo-500/10 dark:ring-indigo-400"
                                          : "border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:border-neutral-600")
                                      }
                                    >
                                      <input
                                        type="radio"
                                        name={`${uid}-plan`}
                                        value={plan.id}
                                        checked={selected}
                                        onChange={() => set("plan", plan.id)}
                                        className="sr-only"
                                      />
                                      <span className="flex items-center justify-between">
                                        <span className="text-sm font-semibold text-neutral-900 dark:text-white">
                                          {plan.name}
                                        </span>
                                        {plan.popular ? (
                                          <span className="rounded-full bg-violet-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-700 dark:bg-violet-500/20 dark:text-violet-300">
                                            Popular
                                          </span>
                                        ) : null}
                                      </span>
                                      <span className="mt-1 text-lg font-bold text-neutral-900 dark:text-white">
                                        {plan.price}
                                        <span className="text-xs font-normal text-neutral-500 dark:text-neutral-400">
                                          {plan.id === "starter" ? "/mo" : "/user"}
                                        </span>
                                      </span>
                                      <span className="mt-1 text-xs leading-relaxed text-neutral-500 dark:text-neutral-400">
                                        {plan.blurb}
                                      </span>
                                    </label>
                                  );
                                })}
                              </div>
                            </fieldset>
                          </>
                        )}

                        {/* Step 3: Team */}
                        {current === 2 && (
                          <>
                            <div>
                              <label
                                htmlFor={`${uid}-invite`}
                                className="mb-1.5 block text-sm font-medium text-neutral-800 dark:text-neutral-200"
                              >
                                Invite by email
                              </label>
                              <div className="flex gap-2">
                                <input
                                  ref={inviteInputRef}
                                  id={`${uid}-invite`}
                                  type="email"
                                  value={inviteDraft}
                                  placeholder="teammate@company.com"
                                  aria-invalid={inviteError ? true : undefined}
                                  aria-describedby={inviteError ? `${uid}-invite-err` : undefined}
                                  onChange={(e) => {
                                    setInviteDraft(e.target.value);
                                    if (inviteError) setInviteError("");
                                  }}
                                  onKeyDown={(e) => {
                                    if (e.key === "Enter") {
                                      e.preventDefault();
                                      e.stopPropagation();
                                      addInvite();
                                    }
                                  }}
                                  className={
                                    "w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 transition-colors dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-500 " +
                                    (inviteError
                                      ? "border-rose-400 dark:border-rose-500/70 "
                                      : "border-neutral-300 hover:border-neutral-400 dark:border-neutral-700 dark:hover:border-neutral-600 ") +
                                    FOCUS
                                  }
                                />
                                <button
                                  type="button"
                                  onClick={addInvite}
                                  className={
                                    "shrink-0 rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200 " +
                                    FOCUS
                                  }
                                >
                                  Add
                                </button>
                              </div>
                              {inviteError ? (
                                <p
                                  id={`${uid}-invite-err`}
                                  className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                                >
                                  {inviteError}
                                </p>
                              ) : (
                                <p className="mt-1.5 text-xs text-neutral-500 dark:text-neutral-400">
                                  Press Enter to add each address.
                                </p>
                              )}
                            </div>

                            {data.invites.length > 0 ? (
                              <ul className="flex flex-wrap gap-2" aria-label="Pending invitations">
                                {data.invites.map((email) => (
                                  <li
                                    key={email}
                                    className="inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-50 py-1 pl-3 pr-1.5 text-sm text-neutral-700 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200"
                                  >
                                    <span className="max-w-[14rem] truncate">{email}</span>
                                    <button
                                      type="button"
                                      onClick={() => removeInvite(email)}
                                      aria-label={`Remove ${email}`}
                                      className={
                                        "flex h-5 w-5 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-200 hover:text-neutral-700 dark:hover:bg-neutral-700 dark:hover:text-neutral-100 " +
                                        FOCUS
                                      }
                                    >
                                      <svg viewBox="0 0 20 20" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                                        <path d="M5 5l10 10M15 5L5 15" strokeLinecap="round" />
                                      </svg>
                                    </button>
                                  </li>
                                ))}
                              </ul>
                            ) : (
                              <p className="rounded-lg border border-dashed border-neutral-300 px-4 py-6 text-center text-sm text-neutral-400 dark:border-neutral-700 dark:text-neutral-500">
                                No one added yet — it's just you for now.
                              </p>
                            )}

                            <fieldset>
                              <legend className="mb-2 text-sm font-medium text-neutral-800 dark:text-neutral-200">
                                Default role for invitees
                              </legend>
                              <div className="grid gap-3 sm:grid-cols-2">
                                {(
                                  [
                                    { id: "member" as Role, name: "Member", desc: "Can view and edit projects." },
                                    { id: "admin" as Role, name: "Admin", desc: "Can manage members and billing." },
                                  ]
                                ).map((r) => {
                                  const selected = data.role === r.id;
                                  return (
                                    <label
                                      key={r.id}
                                      className={
                                        "flex cursor-pointer items-start gap-3 rounded-xl border p-4 transition-colors " +
                                        (selected
                                          ? "border-indigo-500 bg-indigo-50/70 ring-1 ring-indigo-500 dark:border-indigo-400 dark:bg-indigo-500/10 dark:ring-indigo-400"
                                          : "border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:border-neutral-600")
                                      }
                                    >
                                      <input
                                        type="radio"
                                        name={`${uid}-role`}
                                        value={r.id}
                                        checked={selected}
                                        onChange={() => set("role", r.id)}
                                        className="sr-only"
                                      />
                                      <span
                                        aria-hidden="true"
                                        className={
                                          "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 " +
                                          (selected
                                            ? "border-indigo-500 dark:border-indigo-400"
                                            : "border-neutral-300 dark:border-neutral-600")
                                        }
                                      >
                                        {selected ? (
                                          <span className="h-2 w-2 rounded-full bg-indigo-500 dark:bg-indigo-400" />
                                        ) : null}
                                      </span>
                                      <span className="flex flex-col">
                                        <span className="text-sm font-semibold text-neutral-900 dark:text-white">
                                          {r.name}
                                        </span>
                                        <span className="text-xs text-neutral-500 dark:text-neutral-400">{r.desc}</span>
                                      </span>
                                    </label>
                                  );
                                })}
                              </div>
                            </fieldset>

                            <label className="flex cursor-pointer items-center gap-3">
                              <button
                                type="button"
                                role="switch"
                                aria-checked={data.notify}
                                onClick={() => set("notify", !data.notify)}
                                className={
                                  "relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors " +
                                  (data.notify ? "bg-indigo-500" : "bg-neutral-300 dark:bg-neutral-700") +
                                  " " +
                                  FOCUS
                                }
                              >
                                <span
                                  className={
                                    "inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform " +
                                    (data.notify ? "translate-x-5" : "translate-x-0.5")
                                  }
                                />
                              </button>
                              <span className="text-sm text-neutral-700 dark:text-neutral-300">
                                Email me when someone accepts an invite
                              </span>
                            </label>
                          </>
                        )}

                        {/* Step 4: Review */}
                        {current === 3 && (
                          <dl className="divide-y divide-neutral-200 rounded-xl border border-neutral-200 dark:divide-neutral-800 dark:border-neutral-800">
                            {[
                              {
                                label: "Account email",
                                value: data.email || "—",
                                step: 0,
                              },
                              {
                                label: "Workspace",
                                value: `${data.workspaceName || "—"}  ·  ${data.subdomain || "—"}.cadence.app`,
                                step: 1,
                              },
                              {
                                label: "Plan",
                                value: PLANS.find((p) => p.id === data.plan)?.name ?? "—",
                                step: 1,
                              },
                              {
                                label: "Invites",
                                value:
                                  data.invites.length > 0
                                    ? `${data.invites.length} · ${data.role} role`
                                    : "None yet",
                                step: 2,
                              },
                            ].map((row) => (
                              <div
                                key={row.label}
                                className="flex items-center justify-between gap-4 px-4 py-3.5"
                              >
                                <div className="min-w-0">
                                  <dt className="text-xs font-medium uppercase tracking-wide text-neutral-400 dark:text-neutral-500">
                                    {row.label}
                                  </dt>
                                  <dd className="mt-0.5 truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
                                    {row.value}
                                  </dd>
                                </div>
                                <button
                                  type="button"
                                  onClick={() => goToStep(row.step)}
                                  className={
                                    "shrink-0 rounded-md px-2 py-1 text-xs font-semibold text-indigo-600 transition-colors hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10 " +
                                    FOCUS
                                  }
                                >
                                  Edit
                                </button>
                              </div>
                            ))}
                          </dl>
                        )}
                      </div>
                    </div>
                  </motion.div>
                </AnimatePresence>

                {/* Footer controls */}
                <div className="mt-8 flex items-center justify-between gap-3 border-t border-neutral-200 pt-6 dark:border-neutral-800">
                  <button
                    type="button"
                    onClick={handleBack}
                    disabled={current === 0}
                    className={
                      "inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2.5 text-sm font-medium transition-colors " +
                      (current === 0
                        ? "cursor-not-allowed text-neutral-300 dark:text-neutral-600"
                        : "text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800") +
                      " " +
                      FOCUS
                    }
                  >
                    <svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                      <path d="M12 5l-5 5 5 5" strokeLinecap="round" strokeLinejoin="round" />
                    </svg>
                    Back
                  </button>

                  <div className="flex items-center gap-3">
                    <span className="hidden text-xs text-neutral-400 sm:inline dark:text-neutral-500">
                      Step {current + 1} of {STEPS.length}
                    </span>
                    <button
                      type="submit"
                      className={
                        "inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-indigo-600 to-violet-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:from-indigo-500 hover:to-violet-500 active:scale-[.98] " +
                        FOCUS
                      }
                    >
                      {isReview ? "Create workspace" : "Continue"}
                      {isReview ? (
                        <svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                          <path d="M4 10.5 8 14.5 16 5.5" strokeLinecap="round" strokeLinejoin="round" />
                        </svg>
                      ) : (
                        <svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                          <path d="M8 5l5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
                        </svg>
                      )}
                    </button>
                  </div>
                </div>
              </form>
            )}
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →