Skip to content
UI ComponentStepper & WizardFormNew

Multi-Step Onboarding Wizard with Validation & Draft Save

A four-step sign-up wizard — account, profile, plan and review — with an animated stepper, per-step validation and a shake on errors, a live password strength meter, avatar / role / team-size / goal pickers, monthly-yearly billing with plan cards and add-ons that update a live total, a review page with edit links, a saved draft that is restored on return, direction-aware slide transitions and a canvas confetti finish.

  • Published 15 Oct 2026
  • 7 views
Multi-Step Onboarding Wizard with Validation & Draft Save — preview

Overview

Multi-Step Onboarding Wizard with Validation & Draft Save

Onboarding people finish — four friendly steps, live validation, a saved draft and confetti at the end.


What's Included

  • Animated stepper — completed steps are clickable
  • Per-step validation with inline errors and a shake
  • Password strength meter with a rule checklist
  • Avatar, role, team-size and goal pickers
  • Plans with monthly / yearly billing, add-ons and a live total
  • Review page with Edit links back to any step
  • Draft saved to localStorage and restored (password never stored), confetti finish

Dependencies

Already linked inside the component's files — no extra setup needed.


Desktop Preview


HTML

html
1<!doctype html>
2<html lang="en">
3  <head>
4    <meta charset="UTF-8" />
5    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6    <title>Multi-Step Onboarding Wizard | MetaSkillGroup</title>
7    <link rel="stylesheet" href="style.css" />
8  </head>
9  <body>
10    <!-- created by metaskillgroup-team -->
11    <main class="page">
12      <section class="wizard" aria-labelledby="wiz-title">
13        <header class="wiz-head">
14          <span class="brand"><i aria-hidden="true"></i>Craftly</span>
15          <span class="step-count" id="step-count">Step 1 of 4</span>
16        </header>
17
18        <div class="draft" id="draft" hidden>
19          <span>💾 We restored your saved progress.</span>
20          <button type="button" id="start-over">Start over</button>
21        </div>
22
23        <!-- Stepper -->
24        <ol class="stepper" id="stepper">
25          <li class="track" aria-hidden="true"><i id="track-fill"></i></li>
26        </ol>
27
28        <form id="wizard" novalidate>
29          <!-- Step 1 -->
30          <fieldset class="step" data-step="0">
31            <legend id="wiz-title">Create your account</legend>
32            <p class="lead">It takes less than a minute. No credit card needed.</p>
33            <div class="grid-2">
34              <label class="field">
35                <span>Full name</span>
36                <input name="name" autocomplete="name" placeholder="Ankul Agarwal" />
37                <small class="err"></small>
38              </label>
39              <label class="field">
40                <span>Work email</span>
41                <input name="email" type="email" autocomplete="email" placeholder="you@company.com" />
42                <small class="err"></small>
43              </label>
44            </div>
45            <label class="field">
46              <span>Password</span>
47              <div class="pw">
48                <input name="password" type="password" autocomplete="new-password" placeholder="At least 8 characters" />
49                <button type="button" class="eye" aria-label="Show password" aria-pressed="false">👁</button>
50              </div>
51              <div class="meter" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
52              <ul class="checks" id="pw-checks">
53                <li data-rule="len">8+ characters</li>
54                <li data-rule="num">A number</li>
55                <li data-rule="upper">An uppercase letter</li>
56                <li data-rule="sym">A symbol</li>
57              </ul>
58              <small class="err"></small>
59            </label>
60          </fieldset>
61
62          <!-- Step 2 -->
63          <fieldset class="step" data-step="1" hidden>
64            <legend>Tell us about you</legend>
65            <p class="lead">We'll tailor templates and tips to how you work.</p>
66            <div class="field">
67              <span>Pick an avatar</span>
68              <div class="avatars" id="avatars" role="radiogroup" aria-label="Avatar"></div>
69            </div>
70            <div class="field">
71              <span>Your role</span>
72              <div class="chips" data-name="role" data-single role="radiogroup" aria-label="Role">
73                <button type="button" data-value="Designer">🎨 Designer</button>
74                <button type="button" data-value="Developer">💻 Developer</button>
75                <button type="button" data-value="Founder">🚀 Founder</button>
76                <button type="button" data-value="Student">🎓 Student</button>
77                <button type="button" data-value="Other">✨ Other</button>
78              </div>
79              <small class="err"></small>
80            </div>
81            <div class="field">
82              <span>Team size</span>
83              <div class="segment" data-name="team" data-single role="radiogroup" aria-label="Team size">
84                <button type="button" data-value="Just me">Just me</button>
85                <button type="button" data-value="2–10">2–10</button>
86                <button type="button" data-value="11–50">11–50</button>
87                <button type="button" data-value="50+">50+</button>
88              </div>
89              <small class="err"></small>
90            </div>
91            <div class="field">
92              <span>What will you build? <em>(pick any)</em></span>
93              <div class="chips" data-name="goals" aria-label="Goals">
94                <button type="button" data-value="Websites">Websites</button>
95                <button type="button" data-value="Mobile apps">Mobile apps</button>
96                <button type="button" data-value="Dashboards">Dashboards</button>
97                <button type="button" data-value="Landing pages">Landing pages</button>
98                <button type="button" data-value="Portfolios">Portfolios</button>
99              </div>
100              <small class="err"></small>
101            </div>
102          </fieldset>
103
104          <!-- Step 3 -->
105          <fieldset class="step" data-step="2" hidden>
106            <legend>Choose your plan</legend>
107            <p class="lead">Switch or cancel anytime. Yearly saves 20%.</p>
108            <div class="billing" role="radiogroup" aria-label="Billing">
109              <button type="button" data-billing="monthly" aria-checked="true" role="radio">Monthly</button>
110              <button type="button" data-billing="yearly" aria-checked="false" role="radio">Yearly <b>-20%</b></button>
111            </div>
112            <div class="plans" id="plans" role="radiogroup" aria-label="Plan"></div>
113            <div class="field">
114              <span>Add-ons</span>
115              <div class="addons" id="addons"></div>
116            </div>
117            <div class="total">Total <b id="total">₹0</b><small id="total-note">/ month</small></div>
118          </fieldset>
119
120          <!-- Step 4 -->
121          <fieldset class="step" data-step="3" hidden>
122            <legend>Review &amp; confirm</legend>
123            <p class="lead">Check everything looks right. You can edit any section.</p>
124            <div class="review" id="review"></div>
125            <label class="terms">
126              <input type="checkbox" name="terms" />
127              <span class="box" aria-hidden="true"></span>
128              <span>I agree to the <a href="#">Terms</a> and <a href="#">Privacy Policy</a>.</span>
129            </label>
130            <small class="err" id="terms-err"></small>
131          </fieldset>
132
133          <!-- Success -->
134          <div class="success" id="success" hidden>
135            <div class="badge" aria-hidden="true">🎉</div>
136            <h2>Welcome aboard, <span id="success-name"></span>!</h2>
137            <p>Your workspace is ready. We've emailed your receipt and a quick-start guide.</p>
138            <a href="#" class="btn primary">Go to dashboard →</a>
139          </div>
140
141          <footer class="wiz-foot" id="wiz-foot">
142            <button type="button" class="btn ghost" id="back">← Back</button>
143            <span class="hint">Press <kbd>Enter</kbd> to continue</span>
144            <button type="submit" class="btn primary" id="next">
145              <span class="label">Continue</span>
146              <span class="spinner" aria-hidden="true"></span>
147            </button>
148          </footer>
149        </form>
150      </section>
151    </main>
152
153    <canvas class="confetti" id="confetti" aria-hidden="true"></canvas>
154
155    <script src="script.js"></script>
156  </body>
157</html>

CSS

css
1/* Importing Google Fonts - Red Hat Display */
2@import url("https://fonts.googleapis.com/css2?family=Red+Hat+Display:wght@400;500;600;700;800;900&display=swap");
3
4* {
5  margin: 0;
6  padding: 0;
7  box-sizing: border-box;
8  font-family: "Red Hat Display", sans-serif;
9}
10
11:root {
12  --card: #ffffff;
13  --text: #1d1a2b;
14  --muted: #6d6880;
15  --faint: #a29db3;
16  --border: #ebe7f0;
17  --field: #faf9fc;
18  --accent: #ff6b1a;
19  --accent-soft: #fff1e8;
20  --good: #16a34a;
21  --bad: #e11d48;
22  --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
23}
24
25body {
26  min-height: 100vh;
27  background:
28    radial-gradient(circle at 0% 0%, #ffe3cf, transparent 45%),
29    radial-gradient(circle at 100% 100%, #e6dcff, transparent 45%),
30    #fbf7f4;
31  color: var(--text);
32}
33
34[hidden] {
35  display: none !important;
36}
37
38kbd {
39  padding: 1px 6px;
40  border: 1px solid var(--border);
41  border-bottom-width: 2px;
42  border-radius: 6px;
43  background: var(--card);
44  font-family: inherit;
45  font-size: 0.75rem;
46  font-weight: 700;
47}
48
49.page {
50  display: grid;
51  place-items: center;
52  min-height: 100vh;
53  padding: 40px 16px;
54}
55
56.wizard {
57  width: 100%;
58  max-width: 720px;
59  padding: 26px 32px 24px;
60  border-radius: 28px;
61  background: var(--card);
62  box-shadow: 0 40px 90px -45px rgba(29, 26, 43, 0.45);
63}
64
65.wiz-head {
66  display: flex;
67  justify-content: space-between;
68  align-items: center;
69}
70
71.brand {
72  display: flex;
73  align-items: center;
74  gap: 8px;
75  font-size: 1.15rem;
76  font-weight: 900;
77  letter-spacing: -0.02em;
78}
79
80.brand i {
81  width: 22px;
82  height: 22px;
83  border-radius: 7px;
84  background: linear-gradient(135deg, var(--accent), #ff2e93);
85  transform: rotate(12deg);
86}
87
88.step-count {
89  color: var(--muted);
90  font-size: 0.85rem;
91  font-weight: 600;
92}
93
94.draft {
95  display: flex;
96  justify-content: space-between;
97  align-items: center;
98  gap: 10px;
99  margin-top: 16px;
100  padding: 10px 14px;
101  border-radius: 12px;
102  background: #fff7e6;
103  color: #92400e;
104  font-size: 0.86rem;
105  font-weight: 600;
106}
107
108.draft button {
109  border: 0;
110  background: none;
111  color: var(--accent);
112  font-weight: 800;
113  cursor: pointer;
114}
115
116/* ---------- Stepper ---------- */
117.stepper {
118  position: relative;
119  display: flex;
120  justify-content: space-between;
121  margin: 26px 0 30px;
122  list-style: none;
123}
124
125.stepper .track {
126  position: absolute;
127  top: 17px;
128  left: 18px;
129  right: 18px;
130  height: 3px;
131  border-radius: 3px;
132  background: var(--border);
133}
134
135.stepper .track i {
136  display: block;
137  width: var(--progress, 0%);
138  height: 100%;
139  border-radius: inherit;
140  background: linear-gradient(90deg, var(--accent), #ff2e93);
141  transition: width 0.5s var(--ease);
142}
143
144.stepper li.s {
145  position: relative;
146  z-index: 1;
147  display: grid;
148  justify-items: center;
149  gap: 8px;
150}
151
152.s button {
153  display: grid;
154  place-items: center;
155  width: 37px;
156  height: 37px;
157  border: 2px solid var(--border);
158  border-radius: 50%;
159  background: var(--card);
160  color: var(--faint);
161  font-weight: 800;
162  cursor: default;
163  transition: all 0.35s var(--ease);
164}
165
166.s span {
167  color: var(--faint);
168  font-size: 0.82rem;
169  font-weight: 700;
170  transition: color 0.3s;
171}
172
173.s.is-current button {
174  border-color: var(--accent);
175  color: var(--accent);
176  box-shadow: 0 0 0 6px var(--accent-soft);
177}
178
179.s.is-current span,
180.s.is-done span {
181  color: var(--text);
182}
183
184.s.is-done button {
185  border-color: transparent;
186  background: linear-gradient(135deg, var(--accent), #ff2e93);
187  color: #fff;
188  cursor: pointer;
189}
190
191.s.is-done button:hover {
192  transform: scale(1.08);
193}
194
195/* ---------- Steps ---------- */
196form {
197  overflow: hidden;
198}
199
200.step {
201  border: 0;
202}
203
204.step.enter-right {
205  animation: inRight 0.45s var(--ease);
206}
207
208.step.enter-left {
209  animation: inLeft 0.45s var(--ease);
210}
211
212@keyframes inRight {
213  from {
214    opacity: 0;
215    transform: translateX(40px);
216  }
217}
218
219@keyframes inLeft {
220  from {
221    opacity: 0;
222    transform: translateX(-40px);
223  }
224}
225
226legend {
227  font-size: 1.6rem;
228  font-weight: 800;
229  letter-spacing: -0.02em;
230}
231
232.lead {
233  margin: 4px 0 22px;
234  color: var(--muted);
235}
236
237.grid-2 {
238  display: grid;
239  grid-template-columns: 1fr 1fr;
240  gap: 14px;
241}
242
243.field {
244  display: grid;
245  gap: 7px;
246  margin-bottom: 16px;
247}
248
249.field > span {
250  font-size: 0.88rem;
251  font-weight: 700;
252}
253
254.field em {
255  color: var(--faint);
256  font-style: normal;
257  font-weight: 500;
258}
259
260.field input {
261  width: 100%;
262  height: 48px;
263  padding: 0 14px;
264  border: 1.5px solid var(--border);
265  border-radius: 13px;
266  background: var(--field);
267  color: var(--text);
268  font-size: 1rem;
269  outline: none;
270  transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
271}
272
273.field input:focus {
274  border-color: var(--accent);
275  background: #fff;
276  box-shadow: 0 0 0 4px var(--accent-soft);
277}
278
279.field.invalid input {
280  border-color: var(--bad);
281  background: #fff6f8;
282}
283
284.err {
285  min-height: 0;
286  color: var(--bad);
287  font-size: 0.8rem;
288  font-weight: 700;
289}
290
291.err:empty {
292  display: none;
293}
294
295/* Password */
296.pw {
297  position: relative;
298}
299
300.pw input {
301  padding-right: 48px;
302}
303
304.eye {
305  position: absolute;
306  top: 50%;
307  right: 8px;
308  width: 34px;
309  height: 34px;
310  border: 0;
311  border-radius: 9px;
312  background: none;
313  font-size: 1rem;
314  cursor: pointer;
315  transform: translateY(-50%);
316  opacity: 0.55;
317}
318
319.eye[aria-pressed="true"] {
320  opacity: 1;
321}
322
323.meter {
324  display: grid;
325  grid-template-columns: repeat(4, 1fr);
326  gap: 6px;
327}
328
329.meter i {
330  height: 5px;
331  border-radius: 5px;
332  background: var(--border);
333  transition: background 0.3s;
334}
335
336.meter[data-score="1"] i:nth-child(-n + 1) { background: #ef4444; }
337.meter[data-score="2"] i:nth-child(-n + 2) { background: #f59e0b; }
338.meter[data-score="3"] i:nth-child(-n + 3) { background: #84cc16; }
339.meter[data-score="4"] i { background: var(--good); }
340
341.checks {
342  display: flex;
343  flex-wrap: wrap;
344  gap: 6px 14px;
345  list-style: none;
346  font-size: 0.8rem;
347  color: var(--faint);
348}
349
350.checks li::before {
351  content: "○ ";
352}
353
354.checks li.ok {
355  color: var(--good);
356}
357
358.checks li.ok::before {
359  content: "✓ ";
360}
361
362/* Avatars */
363.avatars {
364  display: flex;
365  flex-wrap: wrap;
366  gap: 10px;
367}
368
369.avatars button {
370  display: grid;
371  place-items: center;
372  width: 52px;
373  height: 52px;
374  border: 3px solid transparent;
375  border-radius: 50%;
376  background: var(--c);
377  font-size: 1.5rem;
378  cursor: pointer;
379  transition: transform 0.25s var(--ease), border-color 0.2s;
380}
381
382.avatars button:hover {
383  transform: translateY(-3px);
384}
385
386.avatars button[aria-checked="true"] {
387  border-color: var(--text);
388  transform: scale(1.08);
389}
390
391/* Chips + segment */
392.chips {
393  display: flex;
394  flex-wrap: wrap;
395  gap: 8px;
396}
397
398.chips button,
399.segment button {
400  padding: 9px 14px;
401  border: 1.5px solid var(--border);
402  border-radius: 999px;
403  background: var(--card);
404  color: var(--text);
405  font-size: 0.9rem;
406  font-weight: 600;
407  cursor: pointer;
408  transition: all 0.2s;
409}
410
411.chips button[aria-pressed="true"],
412.chips button[aria-checked="true"] {
413  border-color: var(--accent);
414  background: var(--accent-soft);
415  color: #c2410c;
416}
417
418.segment {
419  display: grid;
420  grid-template-columns: repeat(4, 1fr);
421  gap: 4px;
422  padding: 4px;
423  border-radius: 14px;
424  background: var(--field);
425}
426
427.segment button {
428  border: 0;
429  border-radius: 10px;
430  background: none;
431}
432
433.segment button[aria-checked="true"] {
434  background: var(--card);
435  box-shadow: 0 4px 12px -6px rgba(29, 26, 43, 0.4);
436}
437
438/* Billing + plans */
439.billing {
440  display: inline-flex;
441  gap: 4px;
442  margin-bottom: 16px;
443  padding: 4px;
444  border-radius: 12px;
445  background: var(--field);
446}
447
448.billing button {
449  padding: 8px 14px;
450  border: 0;
451  border-radius: 9px;
452  background: none;
453  color: var(--muted);
454  font-size: 0.88rem;
455  font-weight: 700;
456  cursor: pointer;
457}
458
459.billing button b {
460  color: var(--good);
461}
462
463.billing button[aria-checked="true"] {
464  background: var(--card);
465  color: var(--text);
466  box-shadow: 0 4px 12px -6px rgba(29, 26, 43, 0.4);
467}
468
469.plans {
470  display: grid;
471  grid-template-columns: repeat(3, 1fr);
472  gap: 12px;
473  margin-bottom: 18px;
474}
475
476.plan {
477  position: relative;
478  display: grid;
479  gap: 4px;
480  padding: 16px;
481  border: 2px solid var(--border);
482  border-radius: 18px;
483  background: var(--card);
484  color: var(--text);
485  text-align: left;
486  cursor: pointer;
487  transition: border-color 0.2s, transform 0.25s var(--ease), box-shadow 0.25s;
488}
489
490.plan:hover {
491  transform: translateY(-2px);
492}
493
494.plan[aria-checked="true"] {
495  border-color: var(--accent);
496  box-shadow: 0 14px 30px -18px rgba(255, 107, 26, 0.8);
497}
498
499.plan[aria-checked="true"]::after {
500  content: "✓";
501  position: absolute;
502  top: 12px;
503  right: 12px;
504  display: grid;
505  place-items: center;
506  width: 22px;
507  height: 22px;
508  border-radius: 50%;
509  background: var(--accent);
510  color: #fff;
511  font-size: 0.75rem;
512  font-weight: 900;
513}
514
515.plan b {
516  font-size: 1rem;
517}
518
519.plan .price {
520  font-size: 1.5rem;
521  font-weight: 900;
522}
523
524.plan .price small {
525  color: var(--muted);
526  font-size: 0.78rem;
527  font-weight: 600;
528}
529
530.plan p {
531  color: var(--muted);
532  font-size: 0.8rem;
533  line-height: 1.4;
534}
535
536.addons {
537  display: grid;
538  gap: 8px;
539}
540
541.addon {
542  display: flex;
543  align-items: center;
544  gap: 12px;
545  padding: 12px 14px;
546  border: 1.5px solid var(--border);
547  border-radius: 14px;
548  cursor: pointer;
549  transition: border-color 0.2s, background 0.2s;
550}
551
552.addon:has(input:checked) {
553  border-color: var(--accent);
554  background: var(--accent-soft);
555}
556
557.addon input {
558  width: 18px;
559  height: 18px;
560  accent-color: var(--accent);
561}
562
563.addon div {
564  flex: 1;
565}
566
567.addon small {
568  display: block;
569  color: var(--muted);
570}
571
572.addon b {
573  font-size: 0.92rem;
574}
575
576.total {
577  display: flex;
578  align-items: baseline;
579  justify-content: flex-end;
580  gap: 8px;
581  padding-top: 14px;
582  border-top: 1px dashed var(--border);
583  color: var(--muted);
584  font-weight: 700;
585}
586
587.total b {
588  color: var(--text);
589  font-size: 1.6rem;
590  font-weight: 900;
591}
592
593/* Review */
594.review {
595  display: grid;
596  gap: 10px;
597  margin-bottom: 18px;
598}
599
600.r-sec {
601  display: grid;
602  grid-template-columns: 1fr auto;
603  gap: 6px 12px;
604  padding: 14px 16px;
605  border: 1px solid var(--border);
606  border-radius: 16px;
607  background: var(--field);
608}
609
610.r-sec h3 {
611  font-size: 0.8rem;
612  color: var(--muted);
613  letter-spacing: 0.06em;
614  text-transform: uppercase;
615}
616
617.r-sec button {
618  border: 0;
619  background: none;
620  color: var(--accent);
621  font-size: 0.85rem;
622  font-weight: 800;
623  cursor: pointer;
624}
625
626.r-sec dl {
627  grid-column: 1 / -1;
628  display: grid;
629  grid-template-columns: 120px 1fr;
630  gap: 4px 12px;
631  font-size: 0.92rem;
632}
633
634.r-sec dt {
635  color: var(--muted);
636}
637
638.r-sec dd {
639  font-weight: 700;
640}
641
642.terms {
643  display: flex;
644  align-items: center;
645  gap: 10px;
646  color: var(--muted);
647  font-size: 0.92rem;
648  cursor: pointer;
649}
650
651.terms input {
652  position: absolute;
653  opacity: 0;
654}
655
656.terms .box {
657  display: grid;
658  place-items: center;
659  width: 22px;
660  height: 22px;
661  border: 2px solid var(--border);
662  border-radius: 7px;
663  transition: all 0.2s;
664}
665
666.terms input:checked + .box {
667  border-color: var(--accent);
668  background: var(--accent);
669}
670
671.terms input:checked + .box::after {
672  content: "✓";
673  color: #fff;
674  font-size: 0.8rem;
675  font-weight: 900;
676}
677
678.terms input:focus-visible + .box {
679  outline: 3px solid var(--accent-soft);
680}
681
682.terms a {
683  color: var(--accent);
684  font-weight: 700;
685}
686
687/* Success */
688.success {
689  padding: 30px 0 10px;
690  text-align: center;
691  animation: inRight 0.5s var(--ease);
692}
693
694.success .badge {
695  display: inline-grid;
696  place-items: center;
697  width: 86px;
698  height: 86px;
699  border-radius: 50%;
700  background: var(--accent-soft);
701  font-size: 2.6rem;
702  animation: wobble 1s var(--ease);
703}
704
705@keyframes wobble {
706  0% {
707    transform: scale(0) rotate(-30deg);
708  }
709  60% {
710    transform: scale(1.15) rotate(8deg);
711  }
712}
713
714.success h2 {
715  margin: 18px 0 8px;
716  font-size: 1.8rem;
717  font-weight: 900;
718}
719
720.success p {
721  max-width: 400px;
722  margin: 0 auto 22px;
723  color: var(--muted);
724  line-height: 1.6;
725}
726
727/* ---------- Footer buttons ---------- */
728.wiz-foot {
729  display: flex;
730  justify-content: space-between;
731  align-items: center;
732  gap: 12px;
733  margin-top: 22px;
734  padding-top: 18px;
735  border-top: 1px solid var(--border);
736}
737
738.hint {
739  color: var(--faint);
740  font-size: 0.82rem;
741}
742
743.btn {
744  display: inline-flex;
745  align-items: center;
746  gap: 8px;
747  padding: 13px 22px;
748  border: 0;
749  border-radius: 14px;
750  font-size: 0.95rem;
751  font-weight: 800;
752  text-decoration: none;
753  cursor: pointer;
754  transition: transform 0.2s, opacity 0.2s, box-shadow 0.2s;
755}
756
757.btn.primary {
758  background: var(--text);
759  color: #fff;
760}
761
762.btn.primary:hover {
763  box-shadow: 0 12px 26px -12px rgba(29, 26, 43, 0.7);
764  transform: translateY(-1px);
765}
766
767.btn.ghost {
768  background: none;
769  color: var(--muted);
770}
771
772.btn.ghost:disabled {
773  visibility: hidden;
774}
775
776.btn:focus-visible,
777.chips button:focus-visible,
778.segment button:focus-visible,
779.plan:focus-visible,
780.avatars button:focus-visible,
781.billing button:focus-visible,
782.s button:focus-visible {
783  outline: 3px solid rgba(255, 107, 26, 0.45);
784  outline-offset: 2px;
785}
786
787.shake {
788  animation: shake 0.4s;
789}
790
791@keyframes shake {
792  25%,
793  75% {
794    transform: translateX(-6px);
795  }
796  50% {
797    transform: translateX(6px);
798  }
799}
800
801.spinner {
802  display: none;
803  width: 16px;
804  height: 16px;
805  border: 2.5px solid rgba(255, 255, 255, 0.35);
806  border-top-color: #fff;
807  border-radius: 50%;
808  animation: spin 0.7s linear infinite;
809}
810
811.is-loading .spinner {
812  display: inline-block;
813}
814
815@keyframes spin {
816  to {
817    transform: rotate(360deg);
818  }
819}
820
821.confetti {
822  position: fixed;
823  inset: 0;
824  z-index: 50;
825  width: 100%;
826  height: 100%;
827  pointer-events: none;
828}
829
830/* ---------- Responsive ---------- */
831@media (max-width: 640px) {
832  .wizard {
833    padding: 20px 18px;
834    border-radius: 22px;
835  }
836
837  .grid-2,
838  .plans {
839    grid-template-columns: 1fr;
840  }
841
842  .segment {
843    grid-template-columns: repeat(2, 1fr);
844  }
845
846  .s span,
847  .hint {
848    display: none;
849  }
850
851  .r-sec dl {
852    grid-template-columns: 1fr;
853  }
854
855  .r-sec dt {
856    margin-top: 6px;
857  }
858}
859
860@media (prefers-reduced-motion: reduce) {
861  *,
862  *::before,
863  *::after {
864    animation: none !important;
865    transition: none !important;
866  }
867}

JavaScript

javascript
1// ---------- Content (edit freely) ----------
2const STEPS = ["Account", "Profile", "Plan", "Review"];
3const AVATARS = [
4  { emoji: "🦊", c: "#ffe1cc" },
5  { emoji: "🐼", c: "#ececec" },
6  { emoji: "🐸", c: "#dcfce7" },
7  { emoji: "🦄", c: "#f3e8ff" },
8  { emoji: "🐙", c: "#ffe4e6" },
9  { emoji: "🐧", c: "#dbeafe" },
10];
11const PLANS = [
12  { id: "free", name: "Free", monthly: 0, desc: "1 project and community support" },
13  { id: "pro", name: "Pro", monthly: 499, desc: "Unlimited projects and a custom domain" },
14  { id: "team", name: "Team", monthly: 1299, desc: "Everything in Pro plus 10 seats" },
15];
16const ADDONS = [
17  { id: "support", name: "Priority support", monthly: 199, desc: "Replies in under an hour" },
18  { id: "storage", name: "Extra 100 GB storage", monthly: 149, desc: "For big media libraries" },
19];
20const YEARLY_DISCOUNT = 0.2;
21const DRAFT_KEY = "craftly-onboarding";
22
23// ---------- State ----------
24const $ = (id) => document.getElementById(id);
25const form = $("wizard");
26const steps = [...document.querySelectorAll(".step")];
27const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
28const blank = () => ({ name: "", email: "", avatar: 0, role: "", team: "", goals: [], billing: "monthly", plan: "pro", addons: [], terms: false });
29let data = blank();
30let password = ""; // never saved to the draft
31let current = 0;
32
33const inr = (n) => `₹${n.toLocaleString("en-IN")}`;
34const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
35
36// ---------- Draft (localStorage) ----------
37const saveDraft = () => {
38  try {
39    localStorage.setItem(DRAFT_KEY, JSON.stringify({ data, step: current }));
40  } catch {
41    /* storage blocked */
42  }
43};
44const loadDraft = () => {
45  try {
46    return JSON.parse(localStorage.getItem(DRAFT_KEY));
47  } catch {
48    return null;
49  }
50};
51const clearDraft = () => {
52  try {
53    localStorage.removeItem(DRAFT_KEY);
54  } catch {
55    /* storage blocked */
56  }
57};
58
59// ---------- Stepper ----------
60$("stepper").insertAdjacentHTML(
61  "beforeend",
62  STEPS.map((s, i) => `<li class="s" data-i="${i}"><button type="button" aria-label="Step ${i + 1}: ${s}" tabindex="-1">${i + 1}</button><span>${s}</span></li>`).join(""),
63);
64
65const paintStepper = () => {
66  document.querySelectorAll(".s").forEach((li, i) => {
67    li.classList.toggle("is-current", i === current);
68    li.classList.toggle("is-done", i < current);
69    const btn = li.querySelector("button");
70    btn.textContent = i < current ? "✓" : i + 1;
71    btn.tabIndex = i < current ? 0 : -1;
72    i === current ? btn.setAttribute("aria-current", "step") : btn.removeAttribute("aria-current");
73  });
74  $("track-fill").style.setProperty("--progress", `${(current / (STEPS.length - 1)) * 100}%`);
75  $("step-count").textContent = `Step ${current + 1} of ${STEPS.length}`;
76};
77
78$("stepper").addEventListener("click", (e) => {
79  const li = e.target.closest(".s");
80  if (li && Number(li.dataset.i) < current) go(Number(li.dataset.i));
81});
82
83// ---------- Going between steps ----------
84const go = (n) => {
85  const dir = n > current ? "enter-right" : "enter-left";
86  current = n;
87  steps.forEach((s, i) => {
88    s.hidden = i !== n;
89    s.classList.remove("enter-right", "enter-left");
90  });
91  void steps[n].offsetWidth;
92  steps[n].classList.add(dir);
93  if (n === 3) renderReview();
94  paintStepper();
95  $("back").disabled = n === 0;
96  $("next").querySelector(".label").textContent = n === STEPS.length - 1 ? "Create account" : "Continue";
97  steps[n].querySelector("input:not([type=checkbox]), button")?.focus({ preventScroll: true });
98  saveDraft();
99};
100
101$("back").addEventListener("click", () => current > 0 && go(current - 1));
102
103// ---------- Step 1: account ----------
104const pwRules = {
105  len: (p) => p.length >= 8,
106  num: (p) => /\d/.test(p),
107  upper: (p) => /[A-Z]/.test(p),
108  sym: (p) => /[^A-Za-z0-9]/.test(p),
109};
110const pwScore = (p) => Object.values(pwRules).filter((r) => r(p)).length;
111
112form.elements.password.addEventListener("input", (e) => {
113  password = e.target.value;
114  document.querySelector(".meter").dataset.score = password ? pwScore(password) : 0;
115  document.querySelectorAll("#pw-checks li").forEach((li) => li.classList.toggle("ok", pwRules[li.dataset.rule](password)));
116});
117document.querySelector(".eye").addEventListener("click", (e) => {
118  const input = form.elements.password;
119  const show = input.type === "password";
120  input.type = show ? "text" : "password";
121  e.currentTarget.setAttribute("aria-pressed", show);
122  e.currentTarget.setAttribute("aria-label", show ? "Hide password" : "Show password");
123});
124["name", "email"].forEach((n) =>
125  form.elements[n].addEventListener("input", (e) => {
126    data[n] = e.target.value;
127    saveDraft();
128  }),
129);
130
131// ---------- Step 2: avatar + chips ----------
132$("avatars").innerHTML = AVATARS.map((a, i) => `<button type="button" role="radio" data-avatar="${i}" style="--c:${a.c}" aria-label="Avatar ${a.emoji}">${a.emoji}</button>`).join("");
133$("avatars").addEventListener("click", (e) => {
134  const b = e.target.closest("[data-avatar]");
135  if (!b) return;
136  data.avatar = Number(b.dataset.avatar);
137  paintChoices();
138  saveDraft();
139});
140
141document.querySelectorAll("[data-name]").forEach((group) => {
142  const name = group.dataset.name;
143  const single = group.hasAttribute("data-single");
144  group.querySelectorAll("button").forEach((b) => single && b.setAttribute("role", "radio"));
145  group.addEventListener("click", (e) => {
146    const b = e.target.closest("button");
147    if (!b) return;
148    if (single) data[name] = b.dataset.value;
149    else data[name] = data[name].includes(b.dataset.value) ? data[name].filter((v) => v !== b.dataset.value) : [...data[name], b.dataset.value];
150    paintChoices();
151    group.closest(".field").classList.remove("invalid");
152    group.closest(".field").querySelector(".err").textContent = "";
153    saveDraft();
154  });
155});
156
157const paintChoices = () => {
158  document.querySelectorAll("[data-avatar]").forEach((b) => b.setAttribute("aria-checked", Number(b.dataset.avatar) === data.avatar));
159  document.querySelectorAll("[data-name]").forEach((group) => {
160    const v = data[group.dataset.name];
161    group.querySelectorAll("button").forEach((b) => {
162      const on = Array.isArray(v) ? v.includes(b.dataset.value) : v === b.dataset.value;
163      b.setAttribute(group.hasAttribute("data-single") ? "aria-checked" : "aria-pressed", on);
164    });
165  });
166};
167
168// ---------- Step 3: plan, billing, add-ons ----------
169const price = (monthly) => (data.billing === "yearly" ? Math.round(monthly * (1 - YEARLY_DISCOUNT)) : monthly);
170const total = () => {
171  const plan = PLANS.find((p) => p.id === data.plan);
172  return price(plan.monthly) + ADDONS.filter((a) => data.addons.includes(a.id)).reduce((s, a) => s + price(a.monthly), 0);
173};
174
175const renderPlans = () => {
176  $("plans").innerHTML = PLANS.map(
177    (p) => `<button type="button" class="plan" role="radio" data-plan="${p.id}" aria-checked="${p.id === data.plan}">
178      <b>${p.name}</b><span class="price">${inr(price(p.monthly))}<small> /mo</small></span><p>${p.desc}</p></button>`,
179  ).join("");
180  $("addons").innerHTML = ADDONS.map(
181    (a) => `<label class="addon"><input type="checkbox" data-addon="${a.id}" ${data.addons.includes(a.id) ? "checked" : ""} />
182      <div><b>${a.name}</b><small>${a.desc}</small></div><b>+${inr(price(a.monthly))}</b></label>`,
183  ).join("");
184  document.querySelectorAll("[data-billing]").forEach((b) => b.setAttribute("aria-checked", b.dataset.billing === data.billing));
185  $("total").textContent = inr(total());
186  $("total-note").textContent = data.billing === "yearly" ? `/ month, ${inr(total() * 12)} billed yearly` : "/ month";
187};
188
189$("plans").addEventListener("click", (e) => {
190  const b = e.target.closest("[data-plan]");
191  if (!b) return;
192  data.plan = b.dataset.plan;
193  renderPlans();
194  saveDraft();
195});
196$("addons").addEventListener("change", (e) => {
197  const id = e.target.dataset.addon;
198  data.addons = e.target.checked ? [...data.addons, id] : data.addons.filter((a) => a !== id);
199  renderPlans();
200  saveDraft();
201});
202document.querySelectorAll("[data-billing]").forEach((b) =>
203  b.addEventListener("click", () => {
204    data.billing = b.dataset.billing;
205    renderPlans();
206    saveDraft();
207  }),
208);
209
210// ---------- Step 4: review ----------
211const renderReview = () => {
212  const plan = PLANS.find((p) => p.id === data.plan);
213  const addons = ADDONS.filter((a) => data.addons.includes(a.id)).map((a) => a.name).join(", ") || "None";
214  const sec = (title, step, rows) =>
215    `<section class="r-sec"><h3>${title}</h3><button type="button" data-edit="${step}">Edit</button><dl>${rows
216      .map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`)
217      .join("")}</dl></section>`;
218  $("review").innerHTML =
219    sec("Account", 0, [["Name", data.name], ["Email", data.email], ["Password", "•".repeat(Math.max(password.length, 8))]]) +
220    sec("Profile", 1, [["Avatar", AVATARS[data.avatar].emoji], ["Role", data.role], ["Team", data.team], ["Building", data.goals.join(", ")]]) +
221    sec("Plan", 2, [["Plan", `${plan.name} · ${data.billing}`], ["Add-ons", addons], ["Total", `${inr(total())} / month`]]);
222};
223$("review").addEventListener("click", (e) => {
224  const b = e.target.closest("[data-edit]");
225  if (b) go(Number(b.dataset.edit));
226});
227form.elements.terms.addEventListener("change", (e) => {
228  data.terms = e.target.checked;
229  if (data.terms) $("terms-err").textContent = "";
230});
231
232// ---------- Validation ----------
233const setErr = (field, msg) => {
234  field.classList.toggle("invalid", Boolean(msg));
235  field.querySelector(".err").textContent = msg;
236  return !msg;
237};
238
239const validators = [
240  () => {
241    const f = (n) => form.elements[n].closest(".field");
242    const ok = [
243      setErr(f("name"), data.name.trim().length < 2 ? "Please enter your name." : ""),
244      setErr(f("email"), !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(data.email.trim()) ? "Enter a valid email address." : ""),
245      setErr(f("password"), pwScore(password) < 3 ? "Use a stronger password (meet 3 of the 4 rules)." : ""),
246    ];
247    return ok.every(Boolean);
248  },
249  () => {
250    const field = (n) => document.querySelector(`[data-name="${n}"]`).closest(".field");
251    return [
252      setErr(field("role"), data.role ? "" : "Pick the role that fits best."),
253      setErr(field("team"), data.team ? "" : "How big is your team?"),
254      setErr(field("goals"), data.goals.length ? "" : "Choose at least one."),
255    ].every(Boolean);
256  },
257  () => true,
258  () => {
259    $("terms-err").textContent = data.terms ? "" : "Please accept the terms to continue.";
260    return data.terms;
261  },
262];
263
264form.addEventListener("submit", (e) => {
265  e.preventDefault();
266  if (!validators[current]()) {
267    const next = $("next");
268    next.classList.remove("shake");
269    void next.offsetWidth;
270    next.classList.add("shake");
271    steps[current].querySelector(".invalid input, .invalid button")?.focus();
272    return;
273  }
274  if (current < STEPS.length - 1) return go(current + 1);
275  finish();
276});
277
278// ---------- Finish ----------
279const finish = () => {
280  const next = $("next");
281  next.classList.add("is-loading");
282  next.disabled = true;
283  // Fake request — replace with your API call
284  setTimeout(() => {
285    steps.forEach((s) => (s.hidden = true));
286    $("wiz-foot").hidden = true;
287    $("draft").hidden = true;
288    $("success-name").textContent = data.name.trim().split(" ")[0] || "friend";
289    $("success").hidden = false;
290    current = STEPS.length;
291    document.querySelectorAll(".s").forEach((li) => {
292      li.classList.add("is-done");
293      li.classList.remove("is-current");
294      li.querySelector("button").textContent = "✓";
295    });
296    $("track-fill").style.setProperty("--progress", "100%");
297    $("step-count").textContent = "All done";
298    clearDraft();
299    confetti();
300  }, 1200);
301};
302
303// ---------- Confetti (canvas, no library) ----------
304const confetti = () => {
305  if (reduceMotion) return;
306  const canvas = $("confetti");
307  const ctx = canvas.getContext("2d");
308  canvas.width = innerWidth * devicePixelRatio;
309  canvas.height = innerHeight * devicePixelRatio;
310  ctx.scale(devicePixelRatio, devicePixelRatio);
311  const colors = ["#ff6b1a", "#ff2e93", "#7c3aed", "#16a34a", "#facc15", "#38bdf8"];
312  const parts = Array.from({ length: 160 }, () => ({
313    x: innerWidth / 2,
314    y: innerHeight / 2.4,
315    vx: (Math.random() - 0.5) * 16,
316    vy: -Math.random() * 14 - 4,
317    r: Math.random() * Math.PI,
318    vr: (Math.random() - 0.5) * 0.3,
319    w: 6 + Math.random() * 6,
320    h: 8 + Math.random() * 8,
321    c: colors[Math.floor(Math.random() * colors.length)],
322  }));
323  const startT = performance.now();
324  const frame = (t) => {
325    ctx.clearRect(0, 0, innerWidth, innerHeight);
326    parts.forEach((p) => {
327      p.vy += 0.35;
328      p.vx *= 0.99;
329      p.x += p.vx;
330      p.y += p.vy;
331      p.r += p.vr;
332      ctx.save();
333      ctx.translate(p.x, p.y);
334      ctx.rotate(p.r);
335      ctx.fillStyle = p.c;
336      ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
337      ctx.restore();
338    });
339    if (t - startT < 3500) requestAnimationFrame(frame);
340    else ctx.clearRect(0, 0, innerWidth, innerHeight);
341  };
342  requestAnimationFrame(frame);
343};
344
345// ---------- Start (restore a draft if there is one) ----------
346const fill = () => {
347  form.elements.name.value = data.name;
348  form.elements.email.value = data.email;
349  form.elements.terms.checked = data.terms;
350  paintChoices();
351  renderPlans();
352};
353
354$("start-over").addEventListener("click", () => {
355  clearDraft();
356  data = blank();
357  password = "";
358  form.reset();
359  document.querySelector(".meter").dataset.score = 0;
360  document.querySelectorAll("#pw-checks li").forEach((li) => li.classList.remove("ok"));
361  $("draft").hidden = true;
362  fill();
363  go(0);
364});
365
366const draft = loadDraft();
367if (draft?.data && (draft.data.name || draft.data.email || draft.step > 0)) {
368  data = { ...blank(), ...draft.data };
369  $("draft").hidden = false;
370}
371fill();
372// Password is never stored, so a restored draft always reopens on step 1 to re-enter it
373go(0);

Built by MetaSkillGroup — copy it, use it, make it yours.

Features

  • Animated stepper — completed steps are clickable

  • Per-step validation with inline errors and a shake

  • Password strength meter with a rule checklist

  • Avatar, role, team-size and goal pickers

  • Plans with monthly / yearly billing, add-ons and a live total

  • Review page with Edit links back to any step

  • Draft saved to localStorage and restored (password never stored), confetti finish

Built With

🌐

HTML5

🎨

CSS3

⚡

JavaScript

Compatibility

Browsers
ChromeEdgeFirefoxSafariOperaBraveArcVivaldi
Devices
DesktopTabletMobile
Frameworks
Vanilla JSHTML/CSS

❓ FAQs

Add a <fieldset class="step"> in index.html, its name to STEPS and a validator function at the same index in validators (in script.js).

No. The draft keeps everything except the password, so a restored draft always reopens on step 1 to re-enter it.

In finish() replace the setTimeout with a fetch() to your API using the data object (plus the password variable).

MetaSkillGroup

Written by

MetaSkillGroup

October 15, 2026

Need it built for you?

Built to the same standard as our client work.

If nothing here fits exactly, tell us what you need — our core developers build it. Requirements call first, a written SRS before any code, and a price agreed up front.

Multi-Step Form Wizard with Validation (HTML JS) | MetaSkillGroup