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
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
- Google Fonts — Red Hat Display https://fonts.googleapis.com/css2?family=Red+Hat+Display:wght@400;500;600;700;800;900&display=swap
Already linked inside the component's files — no extra setup needed.
Desktop Preview
HTML
html1<!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 & 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
css1/* 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
javascript1// ---------- 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
❓ 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).

Written by
MetaSkillGroup
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.