Wizard Stepper
Original · freewizard stepper with next/back
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"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
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 quoteSimilar components
Browse all →
Horizontal Stepper
Originalhorizontal step indicator

Vertical Stepper
Originalvertical stepper

Numbered Stepper
Originalnumbered stepper with connectors
Icons Stepper
Originalicon stepper

Progress Stepper
Originalstepper with progress fill

Dots Stepper
Originaldot stepper

Checkout Stepper
Originalcheckout progress steps

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

