Multi-step Signup

A three-step onboarding flow with progress, validation, animated transitions and a review step.

  • Forms
  • B2B SaaS
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Uses ResizeObserver, :has() (focus ring on radio cards) and color-mix().

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/multi-step-signup.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/forms/multi-step-signup.tsx
1/**
2 * MultiStepSignup — three-step onboarding form (Account → Workspace → Review)
3 * with a progress stepper, per-step validation, direction-aware step
4 * transitions, a measured height animation and a review step with edit links.
5 *
6 * Props: onComplete(data) (may return a Promise), productDomain (suffix shown
7 * after the workspace URL, default "ledgerline.app"), theme.
8 * Exports: MultiStepSignup, SignupData.
9 *
10 * Browser support: all evergreen browsers (ResizeObserver for height).
11 * Accessibility: a <form> per step (Enter submits), labelled fields with
12 * aria-invalid + aria-describedby, errors announced via role="alert", focus
13 * moves to the step heading on every transition, the stepper is an <ol> with
14 * aria-current="step", completed steps are buttons. Radio cards use native
15 * radios. Reduced motion: transitions are replaced by a short fade.
16 */
17import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
18import type { ReactNode } from "react";
19
20export interface SignupData {
21 name: string;
22 email: string;
23 password: string;
24 company: string;
25 size: string;
26 role: string;
27 slug: string;
28 terms: boolean;
29}
30
31const STEPS = ["Account", "Workspace", "Review"] as const;
32const SIZES = ["1–10", "11–50", "51–200", "200+"];
33const ROLES = ["Finance lead", "RevOps manager", "Founder / CEO", "Engineer", "Other"];
34const FREE = /@(gmail|yahoo|hotmail|outlook|icloud|proton)\./i;
35const TAKEN = ["acme", "demo", "test", "admin", "app", "ledgerline"];
36const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24);
37
38type Errors = Partial<Record<keyof SignupData, string>>;
39
40function validate(step: number, d: SignupData): Errors {
41 const e: Errors = {};
42 if (step === 0) {
43 if (d.name.trim().split(/\s+/).length < 2) e.name = "Enter your first and last name";
44 if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(d.email)) e.email = "Enter a valid email address";
45 else if (FREE.test(d.email)) e.email = "Please use your work email, not a personal one";
46 if (d.password.length < 8) e.password = "At least 8 characters";
47 else if (!/\d/.test(d.password) || !/[a-z]/i.test(d.password)) e.password = "Include a letter and a number";
48 }
49 if (step === 1) {
50 if (!d.company.trim()) e.company = "Tell us your company name";
51 if (!d.size) e.size = "Pick your team size";
52 if (!d.role) e.role = "Choose the closest role";
53 if (d.slug.length < 3) e.slug = "At least 3 characters";
54 else if (TAKEN.includes(d.slug)) e.slug = `“${d.slug}” is taken. Try ${d.slug}-hq`;
55 }
56 if (step === 2 && !d.terms) e.terms = "Accept the terms to continue";
57 return e;
58}
59
60function FieldWrap({ id, label, error, hint, children }: { id: string; label: string; error?: string; hint?: ReactNode; children: ReactNode }) {
61 return (
62 <div className="fd-ms-field" data-bad={error ? "" : undefined}>
63 <label htmlFor={id}>{label}</label>
64 {children}
65 <p className="fd-ms-msg" id={`${id}-m`} role={error ? "alert" : undefined} data-err={error ? "" : undefined}>{error ?? hint}</p>
66 </div>
67 );
68}
69
70export interface MultiStepSignupProps {
71 onComplete?: (d: SignupData) => void | Promise<unknown>;
72 productDomain?: string;
73 theme?: "light" | "dark";
74 className?: string;
75}
76
77export function MultiStepSignup({ onComplete, productDomain = "ledgerline.app", theme, className }: MultiStepSignupProps) {
78 const uid = useId();
79 const [step, setStep] = useState(0);
80 const [dir, setDir] = useState<"next" | "prev">("next");
81 const [furthest, setFurthest] = useState(0);
82 const [d, setD] = useState<SignupData>({ name: "", email: "", password: "", company: "", size: "", role: "", slug: "", terms: false });
83 const [slugTouched, setSlugTouched] = useState(false);
84 const [shown, setShown] = useState<Record<string, boolean>>({});
85 const [pw, setPw] = useState(false);
86 const [phase, setPhase] = useState<"form" | "loading" | "done">("form");
87 const head = useRef<HTMLHeadingElement>(null);
88 const inner = useRef<HTMLDivElement>(null);
89 const [h, setH] = useState<number | undefined>();
90 const first = useRef(true);
91
92 const errors = validate(step, d);
93 const err = (k: keyof SignupData) => (shown[k] ? errors[k] : undefined);
94 const set = <K extends keyof SignupData>(k: K, v: SignupData[K]) => setD((p) => ({ ...p, [k]: v }));
95
96 useLayoutEffect(() => {
97 const el = inner.current;
98 if (!el || typeof ResizeObserver === "undefined") return;
99 const ro = new ResizeObserver(() => setH(el.offsetHeight));
100 ro.observe(el);
101 setH(el.offsetHeight);
102 return () => ro.disconnect();
103 }, [step, phase]);
104
105 useEffect(() => {
106 if (first.current) {
107 first.current = false;
108 return;
109 }
110 head.current?.focus({ preventScroll: true });
111 }, [step, phase]);
112
113 const go = (n: number) => {
114 setDir(n > step ? "next" : "prev");
115 setShown({});
116 setStep(n);
117 setFurthest((f) => Math.max(f, n));
118 };
119
120 const next = async (e: React.FormEvent) => {
121 e.preventDefault();
122 const errs = validate(step, d);
123 const keys = Object.keys(errs) as (keyof SignupData)[];
124 if (keys.length) {
125 setShown(Object.fromEntries(keys.map((k) => [k, true])));
126 requestAnimationFrame(() => document.getElementById(`${uid}-${keys[0]}`)?.focus());
127 return;
128 }
129 if (step < 2) return go(step + 1);
130 setPhase("loading");
131 try {
132 await onComplete?.(d);
133 await new Promise((r) => window.setTimeout(r, 1200));
134 setPhase("done");
135 } catch {
136 setPhase("form");
137 }
138 };
139
140 const common = (k: keyof SignupData) => ({ id: `${uid}-${k}`, "aria-invalid": !!err(k), "aria-describedby": `${uid}-${k}-m`, onBlur: () => String(d[k] ?? "").length > 0 && setShown((s) => ({ ...s, [k]: true })) });
141
142 const slugOk = d.slug.length >= 3 && !TAKEN.includes(d.slug);
143
144 return (
145 <div className={`fd-ms${className ? " " + className : ""}`} data-fd-theme={theme}>
146 <style>{css}</style>
147
148 {phase !== "done" && (
149 <ol className="fd-ms-steps" aria-label="Sign-up progress">
150 {STEPS.map((s, i) => {
151 const state = i < step ? "done" : i === step ? "current" : "todo";
152 const body = (
153 <>
154 <span className="fd-ms-dot" data-state={state}>
155 {state === "done" ? (
156 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
157 ) : (
158 i + 1
159 )}
160 </span>
161 <span className="fd-ms-name">{s}</span>
162 </>
163 );
164 return (
165 <li key={s} className="fd-ms-step" data-state={state} aria-current={i === step ? "step" : undefined}>
166 {i <= furthest && i !== step && phase === "form" ? (
167 <button type="button" className="fd-ms-stepbtn" onClick={() => go(i)} aria-label={`Go back to ${s}`}>{body}</button>
168 ) : (
169 <span className="fd-ms-stepbtn">{body}</span>
170 )}
171 {i < STEPS.length - 1 && <span className="fd-ms-line" aria-hidden="true"><i style={{ transform: `scaleX(${i < step ? 1 : 0})` }} /></span>}
172 </li>
173 );
174 })}
175 </ol>
176 )}
177
178 <div className="fd-ms-viewport" style={{ height: h === undefined ? undefined : h + 16 }}>
179 <div ref={inner} className="fd-ms-inner">
180 {phase === "done" ? (
181 <div className="fd-ms-panel fd-ms-success" key="done" data-dir="next">
182 <span className="fd-ms-bigcheck" aria-hidden="true"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg></span>
183 <h2 ref={head} tabIndex={-1}>You’re in, {d.name.split(" ")[0]}.</h2>
184 <p>Your workspace is ready at <b>{d.slug}.{productDomain}</b>. We sent a confirmation link to {d.email}.</p>
185 <button type="button" className="fd-ms-btn" data-v="primary" onClick={() => { setPhase("form"); setStep(0); setFurthest(0); setShown({}); setD({ name: "", email: "", password: "", company: "", size: "", role: "", slug: "", terms: false }); setSlugTouched(false); }}>Open workspace</button>
186 </div>
187 ) : (
188 <form className="fd-ms-panel" key={step} data-dir={dir} onSubmit={next} noValidate aria-labelledby={`${uid}-h`}>
189 <header>
190 <p className="fd-ms-count">Step {step + 1} of 3</p>
191 <h2 id={`${uid}-h`} ref={head} tabIndex={-1}>{step === 0 ? "Create your account" : step === 1 ? "Set up your workspace" : "Review and create"}</h2>
192 </header>
193
194 {step === 0 && (
195 <div className="fd-ms-fields">
196 <FieldWrap id={`${uid}-name`} label="Full name" error={err("name")}>
197 <input {...common("name")} autoComplete="name" placeholder="Priya Natarajan" value={d.name} onChange={(e) => set("name", e.target.value)} autoFocus />
198 </FieldWrap>
199 <FieldWrap id={`${uid}-email`} label="Work email" error={err("email")} hint="We’ll never share it.">
200 <input {...common("email")} type="email" autoComplete="email" placeholder="priya@northbeam.co" value={d.email} onChange={(e) => set("email", e.target.value)} />
201 </FieldWrap>
202 <FieldWrap id={`${uid}-password`} label="Password" error={err("password")} hint="8+ characters with a letter and a number.">
203 <div className="fd-ms-pw">
204 <input {...common("password")} type={pw ? "text" : "password"} autoComplete="new-password" value={d.password} onChange={(e) => set("password", e.target.value)} />
205 <button type="button" aria-label={pw ? "Hide password" : "Show password"} aria-pressed={pw} onClick={() => setPw(!pw)}>{pw ? "Hide" : "Show"}</button>
206 </div>
207 </FieldWrap>
208 </div>
209 )}
210
211 {step === 1 && (
212 <div className="fd-ms-fields">
213 <FieldWrap id={`${uid}-company`} label="Company name" error={err("company")}>
214 <input
215 {...common("company")}
216 autoComplete="organization"
217 placeholder="Northbeam"
218 value={d.company}
219 onChange={(e) => {
220 const v = e.target.value;
221 setD((p) => ({ ...p, company: v, slug: slugTouched ? p.slug : slugify(v) }));
222 }}
223 autoFocus
224 />
225 </FieldWrap>
226 <div className="fd-ms-field" data-bad={err("size") ? "" : undefined}>
227 <span className="fd-ms-lab" id={`${uid}-size`}>Team size</span>
228 <div className="fd-ms-cards" role="radiogroup" aria-labelledby={`${uid}-size`}>
229 {SIZES.map((s) => (
230 <label key={s} className="fd-ms-card" data-on={d.size === s ? "" : undefined}>
231 <input type="radio" name={`${uid}-sz`} value={s} checked={d.size === s} onChange={() => set("size", s)} />
232 <span>{s}</span>
233 </label>
234 ))}
235 </div>
236 <p className="fd-ms-msg" role={err("size") ? "alert" : undefined} data-err={err("size") ? "" : undefined}>{err("size")}</p>
237 </div>
238 <FieldWrap id={`${uid}-role`} label="Your role" error={err("role")}>
239 <div className="fd-ms-sel">
240 <select {...common("role")} value={d.role} onChange={(e) => set("role", e.target.value)} data-empty={!d.role || undefined}>
241 <option value="">Select a role</option>
242 {ROLES.map((r) => <option key={r}>{r}</option>)}
243 </select>
244 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
245 </div>
246 </FieldWrap>
247 <FieldWrap id={`${uid}-slug`} label="Workspace URL" error={err("slug")} hint={slugOk ? <span className="fd-ms-ok">{d.slug}.{productDomain} is available</span> : "Letters, numbers and dashes."}>
248 <div className="fd-ms-url">
249 <input {...common("slug")} placeholder="northbeam" value={d.slug} onChange={(e) => { setSlugTouched(true); set("slug", slugify(e.target.value)); }} autoCapitalize="none" spellCheck={false} />
250 <span>.{productDomain}</span>
251 </div>
252 </FieldWrap>
253 </div>
254 )}
255
256 {step === 2 && (
257 <div className="fd-ms-review">
258 {[
259 { t: "Account", go: 0, rows: [["Name", d.name], ["Email", d.email], ["Password", "•".repeat(Math.min(d.password.length, 12))]] },
260 { t: "Workspace", go: 1, rows: [["Company", d.company], ["Team size", `${d.size} people`], ["Role", d.role], ["URL", `${d.slug}.${productDomain}`]] },
261 ].map((g) => (
262 <section key={g.t} className="fd-ms-group">
263 <div className="fd-ms-ghead"><h3>{g.t}</h3><button type="button" onClick={() => go(g.go)}>Edit<span className="fd-ms-sr"> {g.t}</span></button></div>
264 <dl>{g.rows.map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
265 </section>
266 ))}
267 <label className="fd-ms-terms" data-bad={err("terms") ? "" : undefined}>
268 <input id={`${uid}-terms`} type="checkbox" checked={d.terms} onChange={(e) => set("terms", e.target.checked)} aria-invalid={!!err("terms")} />
269 <span className="fd-ms-box" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg></span>
270 <span>I agree to the Terms of Service and the Data Processing Addendum.</span>
271 </label>
272 <p className="fd-ms-msg" role={err("terms") ? "alert" : undefined} data-err={err("terms") ? "" : undefined}>{err("terms")}</p>
273 </div>
274 )}
275
276 <footer className="fd-ms-actions">
277 {step > 0 ? <button type="button" className="fd-ms-btn" data-v="ghost" onClick={() => go(step - 1)} disabled={phase === "loading"}>Back</button> : <span />}
278 <button type="submit" className="fd-ms-btn" data-v="primary" data-loading={phase === "loading" || undefined} aria-busy={phase === "loading"}>
279 <span>{step === 2 ? "Create workspace" : "Continue"}</span>
280 <svg className="fd-ms-spin" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity=".25" strokeWidth="2.5" /><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>
281 </button>
282 </footer>
283 </form>
284 )}
285 </div>
286 </div>
287 </div>
288 );
289}
290
291const css = `
292:where(.fd-ms,.fd-ms *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
293:where(.fd-ms,.fd-ms *)::before,:where(.fd-ms,.fd-ms *)::after{box-sizing:border-box}
294:where(.fd-ms svg){display:block;flex-shrink:0}
295:where(.fd-ms button,.fd-ms input,.fd-ms select){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0;outline:0}
296.fd-ms{
297 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;
298 --s-bg:#fff;--s-fg:#0B0B0F;--s-mut:#6B6B76;--s-line:rgba(11,11,15,.16);--s-line-h:rgba(11,11,15,.28);--s-soft:rgba(11,11,15,.05);--s-accent:#007AFF;--s-err:#D70015;--s-ok:#0F7D3F;
299 width:100%;max-width:440px;
300 font:400 15px/1.4 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.005em;color:var(--s-fg);
301}
302[data-fd-theme="dark"] .fd-ms,.fd-ms[data-fd-theme="dark"]{--s-bg:#16161B;--s-fg:#F5F5F7;--s-mut:#9A9AA6;--s-line:rgba(255,255,255,.14);--s-line-h:rgba(255,255,255,.26);--s-soft:rgba(255,255,255,.06);--s-accent:#0A84FF;--s-err:#FF6A60;--s-ok:#5BE382}
303.fd-ms-steps{display:flex;align-items:center;margin-bottom:28px}
304.fd-ms-step{display:flex;align-items:center;flex:none}
305.fd-ms-step:not(:last-child){flex:1}
306.fd-ms-stepbtn{display:inline-flex;align-items:center;gap:10px;appearance:none;-webkit-appearance:none;border:0;outline:0;background:transparent;cursor:default;font-family:inherit;font-size:13px;font-weight:500;line-height:1;color:var(--s-mut);border-radius:9999px;padding:2px 2px 2px 0}
307button.fd-ms-stepbtn{cursor:pointer}
308button.fd-ms-stepbtn:focus-visible{outline:2px solid color-mix(in srgb,var(--s-accent) 70%,transparent);outline-offset:3px}
309.fd-ms-step[data-state="current"] .fd-ms-stepbtn{color:var(--s-fg)}
310.fd-ms-dot{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;font-size:12px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--s-mut);box-shadow:0 0 0 1px var(--s-line);background:var(--s-bg);
311 transition:background-color var(--fd-dur-3) var(--fd-ease-out),color var(--fd-dur-3) var(--fd-ease-out),box-shadow var(--fd-dur-3) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring)}
312.fd-ms-dot[data-state="current"]{color:var(--s-accent);box-shadow:0 0 0 1.5px var(--s-accent),0 0 0 5px color-mix(in srgb,var(--s-accent) 16%,transparent)}
313.fd-ms-dot[data-state="done"]{background:var(--s-accent);color:#fff;box-shadow:0 0 0 1px var(--s-accent)}
314@media (hover:hover){button.fd-ms-stepbtn:hover .fd-ms-dot{transform:scale(1.08)}}
315.fd-ms-line{flex:1;height:2px;margin:0 12px;border-radius:2px;background:var(--s-line);overflow:hidden}
316.fd-ms-line i{display:block;height:100%;background:var(--s-accent);transform-origin:left;transition:transform var(--fd-dur-3) var(--fd-ease-out)}
317.fd-ms-viewport{overflow:hidden;margin:-8px;padding:8px;transition:height var(--fd-dur-3) var(--fd-ease-out)}
318.fd-ms-inner{width:100%}
319.fd-ms-panel{display:flex;flex-direction:column;gap:24px;animation:fd-ms-next var(--fd-dur-3) var(--fd-ease-out) both}
320.fd-ms-panel[data-dir="prev"]{animation-name:fd-ms-prev}
321.fd-ms-count{font-size:13px;line-height:1;font-weight:500;color:var(--s-accent)}
322.fd-ms-panel h2{margin-top:10px;font-size:26px;font-weight:600;line-height:1.15;letter-spacing:-.025em;outline:0;font-family:ui-sans-serif,-apple-system,"SF Pro Display","Inter",sans-serif}
323.fd-ms-fields{display:flex;flex-direction:column;gap:16px}
324.fd-ms-field>label,.fd-ms-lab{display:block;margin:0 0 6px 2px;font-size:13px;font-weight:500;line-height:1.2;color:var(--s-mut)}
325.fd-ms-field input:not([type="radio"]),.fd-ms-field select{display:block;width:100%;height:48px;padding:0 14px;border-radius:12px;background:var(--s-bg);color:var(--s-fg);font-family:inherit;font-size:15px;line-height:1.2;letter-spacing:inherit;
326 box-shadow:0 0 0 1px var(--s-line),0 1px 2px rgba(11,11,15,.04);caret-color:var(--s-accent);transition:box-shadow var(--fd-dur-2) var(--fd-ease-out)}
327@media (hover:hover){.fd-ms-field input:not([type="radio"]):hover,.fd-ms-field select:hover{box-shadow:0 0 0 1px var(--s-line-h),0 1px 2px rgba(11,11,15,.04)}}
328.fd-ms-field input:not([type="radio"]):focus,.fd-ms-field select:focus,.fd-ms-url:focus-within{box-shadow:0 0 0 1.5px var(--s-accent),0 0 0 5px color-mix(in srgb,var(--s-accent) 18%,transparent)}
329.fd-ms-field[data-bad] input:not([type="radio"]),.fd-ms-field[data-bad] select,.fd-ms-field[data-bad] .fd-ms-url{box-shadow:0 0 0 1.5px var(--s-err)}
330.fd-ms-field input::placeholder{color:var(--s-mut);opacity:.6}
331.fd-ms-msg{min-height:18px;padding:6px 2px 0;font-size:12.5px;line-height:1.3;color:var(--s-mut)}
332.fd-ms-msg[data-err]{color:var(--s-err);animation:fd-ms-shake 380ms var(--fd-ease-out)}
333.fd-ms-ok{color:var(--s-ok)}
334.fd-ms-pw{position:relative}.fd-ms-pw input{padding-right:64px!important}
335.fd-ms-pw button{position:absolute;right:8px;top:8px;height:32px;padding:0 10px;border-radius:9999px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:500;line-height:1;color:var(--s-mut);transition:background-color var(--fd-dur-1)}
336.fd-ms-pw button:hover{background:var(--s-soft);color:var(--s-fg)}.fd-ms-pw button:focus-visible{outline:2px solid color-mix(in srgb,var(--s-accent) 70%,transparent);outline-offset:1px}
337.fd-ms-sel{position:relative}.fd-ms-sel select{padding-right:40px;cursor:pointer}.fd-ms-sel select[data-empty]{color:var(--s-mut)}.fd-ms-sel select option{color:#0B0B0F;background:#fff}
338.fd-ms-sel svg{position:absolute;right:14px;top:16px;pointer-events:none;color:var(--s-mut)}
339.fd-ms-url{display:flex;align-items:center;border-radius:12px;background:var(--s-bg);box-shadow:0 0 0 1px var(--s-line),0 1px 2px rgba(11,11,15,.04);transition:box-shadow var(--fd-dur-2) var(--fd-ease-out)}
340.fd-ms-url input{flex:1;min-width:0;box-shadow:none!important;background:transparent!important;padding-right:0!important}
341.fd-ms-url span{padding:0 14px 0 2px;font-size:15px;color:var(--s-mut);white-space:nowrap}
342.fd-ms-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
343.fd-ms-card{position:relative;display:grid;place-items:center;height:48px;margin:0;border-radius:12px;background:var(--s-bg);box-shadow:0 0 0 1px var(--s-line);cursor:pointer;font-size:14px;font-weight:500;color:var(--s-fg);transition:box-shadow var(--fd-dur-2) var(--fd-ease-out),background-color var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring)}
344.fd-ms-card input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
345.fd-ms-card[data-on]{box-shadow:0 0 0 1.5px var(--s-accent);background:color-mix(in srgb,var(--s-accent) 8%,var(--s-bg));color:var(--s-accent)}
346.fd-ms-card:has(input:focus-visible){outline:2px solid color-mix(in srgb,var(--s-accent) 70%,transparent);outline-offset:2px}
347.fd-ms-card:active{transform:scale(.96);transition-duration:var(--fd-dur-1)}
348.fd-ms-review{display:flex;flex-direction:column;gap:12px}
349.fd-ms-group{padding:16px;border-radius:16px;background:var(--s-soft)}
350.fd-ms-ghead{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
351.fd-ms-ghead h3{font-size:13px;font-weight:600;line-height:1.2;color:var(--s-mut);text-transform:uppercase;letter-spacing:.05em}
352.fd-ms-ghead button{cursor:pointer;font-family:inherit;font-size:14px;font-weight:500;line-height:1;color:var(--s-accent);border-radius:6px;padding:2px 4px}
353.fd-ms-ghead button:focus-visible{outline:2px solid color-mix(in srgb,var(--s-accent) 70%,transparent);outline-offset:2px}
354.fd-ms-group dl{display:flex;flex-direction:column;gap:8px}
355.fd-ms-group dl div{display:flex;justify-content:space-between;gap:16px;font-size:14px;line-height:1.3}
356.fd-ms-group dt{color:var(--s-mut)}.fd-ms-group dd{text-align:right;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
357.fd-ms-terms{position:relative;display:flex;gap:12px;align-items:flex-start;margin-top:4px;cursor:pointer;font-size:14px;line-height:1.4;color:var(--s-fg)}
358.fd-ms-terms input{position:absolute;opacity:0;width:20px;height:20px}
359.fd-ms-box{display:grid;place-items:center;flex:none;width:20px;height:20px;margin-top:1px;border-radius:6px;background:var(--s-bg);box-shadow:0 0 0 1px var(--s-line-h);color:transparent;transition:background-color var(--fd-dur-2) var(--fd-ease-out),box-shadow var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring)}
360.fd-ms-terms input:checked+.fd-ms-box{background:var(--s-accent);box-shadow:0 0 0 1px var(--s-accent);color:#fff;transform:scale(1.06)}
361.fd-ms-terms input:focus-visible+.fd-ms-box{outline:2px solid color-mix(in srgb,var(--s-accent) 70%,transparent);outline-offset:2px}
362.fd-ms-terms[data-bad] .fd-ms-box{box-shadow:0 0 0 1.5px var(--s-err)}
363.fd-ms-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:4px}
364.fd-ms-btn{position:relative;display:inline-grid;place-items:center;height:48px;padding:0 26px;border-radius:9999px;cursor:pointer;font-family:inherit;font-size:15px;font-weight:500;line-height:1;letter-spacing:-.005em;
365 transform:scale(1);transition:transform var(--fd-dur-3) var(--fd-spring),background-color var(--fd-dur-2) var(--fd-ease-out),filter var(--fd-dur-1)}
366.fd-ms-btn>*{grid-area:1/1;transition:opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-ease-out)}
367.fd-ms-btn[data-v="primary"]{background:var(--s-accent);color:#fff;min-width:150px;box-shadow:0 1px 0 rgba(255,255,255,.22) inset,0 1px 2px rgba(0,40,120,.35),0 10px 22px -12px var(--s-accent)}
368.fd-ms-btn[data-v="ghost"]{color:var(--s-mut);padding:0 16px}
369@media (hover:hover){.fd-ms-btn[data-v="primary"]:hover{filter:brightness(1.08)}.fd-ms-btn[data-v="ghost"]:hover{color:var(--s-fg);background:var(--s-soft)}}
370.fd-ms-btn:active{transform:scale(.96);transition:transform var(--fd-dur-1) var(--fd-ease-out)}
371.fd-ms-btn:focus-visible{outline:2px solid color-mix(in srgb,var(--s-accent) 70%,transparent);outline-offset:3px}
372.fd-ms-btn:disabled{opacity:.5;cursor:not-allowed}
373.fd-ms-spin{opacity:0;transform:scale(.7);animation:fd-ms-rot .8s linear infinite}
374.fd-ms-btn[data-loading]>span{opacity:0;transform:translateY(-8px)}.fd-ms-btn[data-loading] .fd-ms-spin{opacity:1;transform:none}
375.fd-ms-success{align-items:flex-start;padding-top:8px}
376.fd-ms-success p{font-size:15px;line-height:1.5;color:var(--s-mut);max-width:38ch}.fd-ms-success b{color:var(--s-fg);font-weight:600}
377.fd-ms-bigcheck{display:grid;place-items:center;width:64px;height:64px;border-radius:20px;background:color-mix(in srgb,var(--s-ok) 16%,transparent);color:var(--s-ok);animation:fd-ms-pop 600ms var(--fd-spring)}
378.fd-ms-bigcheck path{stroke-dasharray:24;stroke-dashoffset:24;animation:fd-ms-draw 460ms var(--fd-ease-out) 160ms forwards}
379.fd-ms-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
380@keyframes fd-ms-next{from{opacity:0;transform:translateX(28px)}}
381@keyframes fd-ms-prev{from{opacity:0;transform:translateX(-28px)}}
382@keyframes fd-ms-rot{to{transform:rotate(360deg)}}
383@keyframes fd-ms-draw{to{stroke-dashoffset:0}}
384@keyframes fd-ms-pop{from{transform:scale(.5);opacity:0}}
385@keyframes fd-ms-shake{20%{transform:translateX(-3px)}40%{transform:translateX(3px)}60%{transform:translateX(-2px)}80%{transform:translateX(2px)}}
386@media (max-width:420px){.fd-ms-name{display:none}.fd-ms-cards{grid-template-columns:repeat(2,1fr)}.fd-ms-step[data-state="current"] .fd-ms-name{display:inline}}
387@media (prefers-reduced-motion:reduce){.fd-ms-panel,.fd-ms-panel[data-dir="prev"]{animation:fd-ms-fade 100ms linear both}.fd-ms-viewport,.fd-ms-line i,.fd-ms-dot,.fd-ms-btn,.fd-ms-btn>*{transition:none}.fd-ms-msg[data-err],.fd-ms-bigcheck,.fd-ms-spin{animation:none}.fd-ms-bigcheck path{animation:none;stroke-dashoffset:0}}
388@keyframes fd-ms-fade{from{opacity:0}}
389@media (prefers-contrast:more){.fd-ms-field input:not([type="radio"]),.fd-ms-field select{box-shadow:0 0 0 1px currentColor}}
390`;
391
392/* ───────────────────────────── demo ───────────────────────────── */
393
394export function Demo() {
395 const [theme, setTheme] = useState<"light" | "dark">("light");
396 return (
397 <div className="fd-ms-demo" data-fd-theme={theme}>
398 <style>{demoCss}</style>
399 <aside className="fd-ms-demo-side" aria-hidden="true">
400 <div className="fd-ms-demo-logo"><i />Ledgerline</div>
401 <div className="fd-ms-demo-quote">
402 <p>“We closed our books in 3 days instead of 11. Ledgerline replaced four spreadsheets and a lot of Slack pings.”</p>
403 <div><b>Marta Vasquez</b><span>VP Finance, Northbeam Logistics</span></div>
404 </div>
405 <ul className="fd-ms-demo-stats">
406 <li><b>3.2×</b><span>faster month-end close</span></li>
407 <li><b>1,400</b><span>finance teams onboarded</span></li>
408 <li><b>SOC 2</b><span>Type II certified</span></li>
409 </ul>
410 </aside>
411 <main className="fd-ms-demo-main">
412 <div className="fd-ms-demo-top">
413 <span className="fd-ms-demo-mobilelogo"><i />Ledgerline</span>
414 <span className="fd-ms-demo-have">Have an account? <a href="#signin">Sign in</a></span>
415 <button type="button" className="fd-ms-demo-theme" aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>{theme === "dark" ? "Light" : "Dark"}</button>
416 </div>
417 <div className="fd-ms-demo-form"><MultiStepSignup theme={theme} /></div>
418 </main>
419 </div>
420 );
421}
422
423const demoCss = `
424:where(.fd-ms-demo,.fd-ms-demo *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
425:where(.fd-ms-demo,.fd-ms-demo *)::before,:where(.fd-ms-demo,.fd-ms-demo *)::after{box-sizing:border-box}
426:where(.fd-ms-demo button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}
427.fd-ms-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:ui-sans-serif,-apple-system,"SF Pro Display","Inter",sans-serif;
428 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-dur-2:240ms;
429 --d-bg:#fff;--d-ink:#0B0B0F;--d-mut:#6B6B76;--d-line:rgba(11,11,15,.08);--d-accent:#007AFF;
430 display:grid;grid-template-columns:minmax(0,5fr) minmax(0,6fr);min-height:100vh;background:var(--d-bg);color:var(--d-ink);font:400 15px/1.45 var(--d-font);-webkit-font-smoothing:antialiased;transition:background-color var(--fd-dur-2) var(--fd-ease-out)}
431.fd-ms-demo[data-fd-theme="dark"]{--d-bg:#09090B;--d-ink:#F5F5F7;--d-mut:#9A9AA6;--d-line:rgba(255,255,255,.09);--d-accent:#0A84FF}
432.fd-ms-demo-side{display:flex;flex-direction:column;justify-content:space-between;gap:48px;padding:40px clamp(32px,4vw,64px);background:#0B1B34;color:#fff}
433.fd-ms-demo-logo,.fd-ms-demo-mobilelogo{display:flex;align-items:center;gap:10px;font:700 18px/1 var(--d-disp);letter-spacing:-.03em}
434.fd-ms-demo-logo i,.fd-ms-demo-mobilelogo i{display:block;width:20px;height:20px;border-radius:6px;background:#4DA3FF;position:relative}
435.fd-ms-demo-logo i::after,.fd-ms-demo-mobilelogo i::after{content:"";position:absolute;left:5px;right:5px;bottom:5px;height:3px;border-radius:2px;background:#0B1B34;box-shadow:0 -5px 0 #0B1B34,0 -10px 0 #0B1B34}
436.fd-ms-demo-quote p{font:500 clamp(20px,2vw,26px)/1.35 var(--d-disp);letter-spacing:-.02em;max-width:22em}
437.fd-ms-demo-quote div{display:flex;flex-direction:column;gap:4px;margin-top:24px;font:400 14px/1.3 var(--d-font);color:rgba(255,255,255,.65)}.fd-ms-demo-quote b{font-weight:600;color:#fff}
438.fd-ms-demo-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;padding-top:28px;border-top:1px solid rgba(255,255,255,.14)}
439.fd-ms-demo-stats li{display:flex;flex-direction:column;gap:6px}.fd-ms-demo-stats b{font:600 24px/1 var(--d-disp);letter-spacing:-.03em;font-variant-numeric:tabular-nums}.fd-ms-demo-stats span{font:400 13px/1.3 var(--d-font);color:rgba(255,255,255,.65)}
440.fd-ms-demo-main{display:flex;flex-direction:column;padding:24px clamp(16px,4vw,56px) 56px}
441.fd-ms-demo-top{display:flex;align-items:center;justify-content:flex-end;gap:16px}
442.fd-ms-demo-mobilelogo{display:none;margin-right:auto}.fd-ms-demo-mobilelogo i{background:var(--d-accent)}.fd-ms-demo-mobilelogo i::after{background:var(--d-bg)}
443.fd-ms-demo-have{font:400 14px/1 var(--d-font);color:var(--d-mut)}.fd-ms-demo-have a{font-weight:500;color:var(--d-accent)}
444.fd-ms-demo-have a:focus-visible,.fd-ms-demo-theme:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px;border-radius:4px}
445.fd-ms-demo-theme{cursor:pointer;height:32px;padding:0 14px;border-radius:9999px;box-shadow:0 0 0 1px var(--d-line);font:500 13px/1 var(--d-font);color:var(--d-mut)}
446.fd-ms-demo-theme:hover{color:var(--d-ink)}
447.fd-ms-demo-form{flex:1;display:flex;align-items:center;justify-content:center;padding-top:32px}
448.fd-ms-demo-form>.fd-ms{margin-top:-24px}
449@media (max-width:900px){.fd-ms-demo{grid-template-columns:1fr}.fd-ms-demo-side{display:none}.fd-ms-demo-mobilelogo{display:flex}.fd-ms-demo-have{display:none}.fd-ms-demo-form{align-items:flex-start;padding-top:24px}.fd-ms-demo-top{padding-bottom:8px}}
450`;

More Forms. Same file contract.

Input Field

A floating-label input with helper, error and success states, adornments, counter, clear and password reveal.

OTP Input

Six-digit code entry with auto-advance, paste and SMS autofill, error shake and a success cascade.

Payment Form

Card entry with brand detection, live formatting, Luhn validation and a preview that flips to the CVC.