Open demo

CSS code

HTML
<div><span class="lbl">Create an account, inline errors</span>
<form class="wz" data-wizard aria-labelledby="la-title">
  <div class="wz-brand"><span class="wz-mark"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14h9"/></svg></span>Ledgerly</div>
  <h2 class="wz-title" id="la-title">Create your account</h2>
  <p class="wz-sub">Free for 30 days, then $9 per user a month. No card needed.</p>
  <ol class="wz-track" data-stepper aria-label="Progress">
    <li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Account<span class="sr-only" data-sr></span></li>
    <li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Profile<span class="sr-only" data-sr></span></li>
    <li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Confirm<span class="sr-only" data-sr></span></li>
  </ol>
  <p class="wz-summary" data-summary aria-live="polite"></p>

  <section class="wz-panel" data-step aria-labelledby="la-h1">
    <h3 class="wz-h" id="la-h1" tabindex="-1"><span class="wz-kick">Step 1 of 3</span>Your login details</h3>
    <div class="wz-field"><label for="la-email">Work email</label>
      <input class="wz-input" id="la-email" name="email" type="email" required autocomplete="email" placeholder="[email protected]" aria-describedby="la-email-error"
        data-msg-required="Enter your work email." data-msg-type="Enter an email address like [email protected].">
      <p class="wz-error" id="la-email-error"></p></div>
    <div class="wz-field"><label for="la-pw">Password</label>
      <input class="wz-input" id="la-pw" name="password" type="password" required minlength="10" pattern="(?=.*\d)(?=.*[A-Z]).{10,}" autocomplete="new-password" aria-describedby="la-pw-rules la-pw-error"
        data-msg-required="Create a password." data-msg-short="Use at least 10 characters." data-msg-pattern="Use 10 or more characters with at least one number and one uppercase letter.">
      <ul class="wz-rules" id="la-pw-rules" data-rules="la-pw">
        <li data-rule=".{10,}"><span class="wz-tick"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>At least 10 characters<span class="sr-only" data-sr></span></li>
        <li data-rule="\d"><span class="wz-tick"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>A number<span class="sr-only" data-sr></span></li>
        <li data-rule="[A-Z]"><span class="wz-tick"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>An uppercase letter<span class="sr-only" data-sr></span></li>
      </ul>
      <p class="wz-error" id="la-pw-error"></p></div>
    <div class="wz-field"><label for="la-pw2">Confirm password</label>
      <input class="wz-input" id="la-pw2" name="confirm" type="password" required autocomplete="new-password" data-match="la-pw" aria-describedby="la-pw2-error"
        data-msg-required="Type your password again." data-msg-match="The passwords don't match.">
      <p class="wz-error" id="la-pw2-error"></p></div>
  </section>

  <section class="wz-panel" data-step aria-labelledby="la-h2">
    <h3 class="wz-h" id="la-h2" tabindex="-1"><span class="wz-kick">Step 2 of 3</span>About you</h3>
    <div class="wz-row">
      <div class="wz-field"><label for="la-name">Full name</label>
        <input class="wz-input" id="la-name" name="fullname" required autocomplete="name" placeholder="Dana Whitfield" aria-describedby="la-name-error" data-msg-required="Enter your full name.">
        <p class="wz-error" id="la-name-error"></p></div>
      <div class="wz-field"><label for="la-user">Username</label>
        <input class="wz-input" id="la-user" name="username" required pattern="^[a-z0-9_]{3,20}$" autocomplete="username" autocapitalize="none" spellcheck="false" placeholder="dana_w" aria-describedby="la-user-hint la-user-error"
          data-msg-required="Choose a username." data-msg-pattern="Use 3 to 20 lowercase letters, numbers or underscores.">
        <p class="wz-hint" id="la-user-hint">3–20 lowercase letters, numbers or _</p>
        <p class="wz-error" id="la-user-error"></p></div>
    </div>
    <div class="wz-field"><label for="la-country">Country</label>
      <select class="wz-input" id="la-country" name="country" required autocomplete="country-name" aria-describedby="la-country-error" data-msg-required="Choose the country your business is based in.">
        <option value="">Choose a country</option><option>Australia</option><option>Canada</option><option>Germany</option><option>Ireland</option><option>New Zealand</option><option>United Kingdom</option><option>United States</option>
      </select>
      <p class="wz-error" id="la-country-error"></p></div>
  </section>

  <section class="wz-panel" data-step aria-labelledby="la-h3">
    <h3 class="wz-h" id="la-h3" tabindex="-1"><span class="wz-kick">Step 3 of 3</span>Check and confirm</h3>
    <dl class="wz-review">
      <div><dt>Email</dt><dd data-out="email">None</dd></div>
      <div><dt>Name</dt><dd data-out="fullname">None</dd></div>
      <div><dt>Username</dt><dd data-out="username">None</dd></div>
      <div><dt>Country</dt><dd data-out="country">None</dd></div>
    </dl>
    <div class="wz-check"><span class="wz-box"><input type="checkbox" id="la-terms" name="terms" required aria-describedby="la-terms-error" data-msg-required="Accept the terms to create your account."><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
      <div><label for="la-terms">I agree to the Ledgerly Terms of Service and Privacy Policy</label><p class="wz-error" id="la-terms-error"></p></div></div>
    <div class="wz-check"><span class="wz-box"><input type="checkbox" id="la-news" name="updates"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
      <div><label for="la-news">Email me product updates, about twice a month (optional)</label></div></div>
  </section>

  <section class="wz-panel wz-done" data-done hidden aria-labelledby="la-h4">
    <span class="wz-badge"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5.5" width="17" height="13" rx="2"/><path d="m4 7 8 6 8-6"/></svg></span>
    <h3 class="wz-h" id="la-h4" tabindex="-1">Check your inbox</h3>
    <p class="wz-lead">We sent a confirmation link to <b data-out="email">your email</b>. Open it within 24 hours to activate your account.</p>
    <button type="button" class="wz-btn ghost" data-restart>Start over</button>
  </section>

  <div class="wz-nav">
    <button type="button" class="wz-btn ghost" data-back hidden>Back</button>
    <button type="button" class="wz-btn" data-next hidden>Continue</button>
    <button type="submit" class="wz-btn">Create account</button>
  </div>
</form></div>

<div class="narrow"><span class="lbl">Passwordless sign-in</span>
<form class="wz wz-compact" data-wizard aria-labelledby="pl-title">
  <div class="wz-brand"><span class="wz-mark"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14h9"/></svg></span>Ledgerly</div>
  <h2 class="wz-title" id="pl-title">Sign in to Ledgerly</h2>
  <p class="wz-sub">No password needed. We email you a one-time code.</p>
  <ol class="wz-track" data-stepper aria-label="Progress">
    <li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Email<span class="sr-only" data-sr></span></li>
    <li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Code<span class="sr-only" data-sr></span></li>
  </ol>

  <section class="wz-panel" data-step aria-labelledby="pl-h1">
    <h3 class="wz-h" id="pl-h1" tabindex="-1"><span class="wz-kick">Step 1 of 2</span>What's your email?</h3>
    <div class="wz-field"><label for="pl-email">Work email</label>
      <input class="wz-input" id="pl-email" name="email" type="email" required autocomplete="email" placeholder="[email protected]" aria-describedby="pl-email-error"
        data-msg-required="Enter the email you use for Ledgerly." data-msg-type="Enter an email address like [email protected].">
      <p class="wz-error" id="pl-email-error"></p></div>
  </section>

  <section class="wz-panel" data-step aria-labelledby="pl-h2">
    <h3 class="wz-h" id="pl-h2" tabindex="-1"><span class="wz-kick">Step 2 of 2</span>Enter your code</h3>
    <p class="wz-lead">We sent a 6-digit code to <b data-out="email">your email</b>.</p>
    <div class="wz-field"><label for="pl-code">6-digit code</label>
      <input class="wz-input wz-code" id="pl-code" name="code" required inputmode="numeric" autocomplete="one-time-code" pattern="\d{6}" maxlength="6" aria-describedby="pl-code-hint pl-code-error"
        data-msg-required="Enter the 6-digit code from the email." data-msg-pattern="The code is 6 digits, numbers only.">
      <p class="wz-hint" id="pl-code-hint">The code expires in 10 minutes.</p>
      <p class="wz-error" id="pl-code-error"></p></div>
    <p class="wz-resend">Didn't get it? <button type="button" class="wz-link" data-resend>Resend code</button><span class="wz-note" data-resend-msg aria-live="polite"></span></p>
  </section>

  <section class="wz-panel wz-done" data-done hidden aria-labelledby="pl-h3">
    <span class="wz-badge"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
    <h3 class="wz-h" id="pl-h3" tabindex="-1">You're signed in</h3>
    <p class="wz-lead">Welcome back, <b data-out="email">you</b>. Your books are ready where you left them.</p>
    <button type="button" class="wz-btn ghost" data-restart>Sign out</button>
  </section>

  <div class="wz-nav">
    <button type="button" class="wz-btn" data-next hidden>Email me a code</button>
    <button type="submit" class="wz-btn">Sign in</button>
    <button type="button" class="wz-link" data-back hidden>Use a different email</button>
  </div>
</form></div>
CSS
/* Signup wizard with per-step validation. Native constraints (required, type, pattern, minlength and
   setCustomValidity) decide what is wrong; the script writes a specific message into each field's
   #<id>-error, sets aria-invalid="true" and focuses the first invalid field. Invalid fields get a red border
   plus an icon and text, so colour is never the only signal. Without JavaScript every step shows and the
   browser's own validation runs on submit. */
.wz{--wz-accent:#1d4ed8;--wz-accent-dark:#1e3a8a;--wz-soft:#dbeafe;--wz-err:#b91c1c;--wz-err-soft:#fef2f2;--wz-ok:#047857;
  --wz-ink:#0f172a;--wz-muted:#475569;--wz-line:#cbd5e1;--wz-field:#64748b;
  background:#fff;border-radius:18px;padding:30px 32px;box-shadow:0 1px 2px rgba(15,23,42,.06),0 12px 32px -14px rgba(30,58,138,.22);color:var(--wz-ink)}
.wz [hidden]{display:none!important}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.wz-brand{display:flex;align-items:center;gap:10px;margin-bottom:18px;font-weight:700;letter-spacing:-.01em}
.wz-mark{width:30px;height:30px;border-radius:9px;background:var(--wz-accent);display:grid;place-items:center}
.wz-mark svg{width:16px;height:16px;fill:none;stroke:#fff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
.wz-title{font-size:1.3rem;font-weight:700;letter-spacing:-.015em;margin:0}
.wz-sub{margin:4px 0 0;color:var(--wz-muted);font-size:.92rem}

/* stepper: one bar per step; the current bar is half filled, finished ones fill and get a tick */
.wz-track{list-style:none;margin:24px 0 0;padding:0;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:8px}
.wz-track li{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:.8rem;color:var(--wz-muted)}
.wz-track li::before{content:"";flex:0 0 100%;height:4px;margin-bottom:4px;border-radius:4px;background:var(--wz-line);transition:background-color .25s}
.wz-track svg{display:none;width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
.wz-track [data-state="current"]{color:var(--wz-ink);font-weight:600}
.wz-track [data-state="current"]::before{background:linear-gradient(90deg,var(--wz-accent) 50%,var(--wz-line) 50%)}
.wz-track [data-state="done"]{color:var(--wz-accent-dark);font-weight:600}
.wz-track [data-state="done"]::before{background:var(--wz-accent)}
.wz-track [data-state="done"] svg{display:block}

/* error summary: a polite live region that stays in the page and only draws a box when it has text */
.wz-summary{margin:0;font-size:.88rem;font-weight:600;color:var(--wz-err)}
.wz-summary:not(:empty){display:flex;align-items:center;gap:9px;margin-top:22px;padding:10px 14px;border:1px solid #fecaca;border-radius:10px;background:var(--wz-err-soft)}

/* step panels */
.wz-panel{margin-top:26px;display:grid;gap:18px;animation:wz-in .3s ease}
@keyframes wz-in{from{opacity:0;transform:translateY(6px)}}
.wz-h{margin:0;font-size:1.15rem;font-weight:700;letter-spacing:-.01em}
.wz-h:focus{outline:none}
.wz-kick{display:block;font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--wz-accent-dark);margin-bottom:2px}
.wz-lead{margin:-10px 0 0;color:var(--wz-muted);font-size:.92rem}
.wz-lead b{color:var(--wz-ink);overflow-wrap:anywhere}
.wz-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:18px}
.wz-field{display:grid;align-content:start;gap:6px;min-width:0}
.wz-field > label{font-size:.88rem;font-weight:600}
.wz-hint{margin:0;font-size:.8rem;color:var(--wz-muted)}
.wz-input{width:100%;font:inherit;font-size:.95rem;color:var(--wz-ink);background:#fff;border:1.5px solid var(--wz-field);border-radius:10px;padding:10px 12px;min-height:44px;transition:border-color .2s,box-shadow .2s}
.wz-input::placeholder{color:#64748b}
.wz-input:focus-visible{outline:none;border-color:var(--wz-accent);box-shadow:0 0 0 4px var(--wz-soft)}
.wz-input[aria-invalid="true"]{border-color:var(--wz-err)}
.wz-input[aria-invalid="true"]:focus-visible{box-shadow:0 0 0 4px #fecaca}
.wz-code{min-height:58px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:1.6rem;font-weight:700;letter-spacing:.5em;text-indent:.5em;text-align:center}

/* inline error: empty until the script writes a message; the "!" badge is decorative (alt text "") */
.wz-error{display:flex;align-items:flex-start;gap:7px;margin:0;font-size:.84rem;font-weight:500;color:var(--wz-err)}
.wz-error:empty{display:none}
.wz-error::before,.wz-summary:not(:empty)::before{content:"!";content:"!" / "";flex:none;display:grid;place-items:center;width:17px;height:17px;margin-top:1px;border-radius:50%;background:var(--wz-err);color:#fff;font-size:.72rem;font-weight:800;line-height:1}

/* password checklist: each rule ticks green as it is met */
.wz-rules{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px 16px;font-size:.8rem;color:var(--wz-muted)}
.wz-rules li{display:flex;align-items:center;gap:6px;transition:color .2s}
.wz-tick{flex:none;width:16px;height:16px;border-radius:50%;border:1.5px solid var(--wz-field);display:grid;place-items:center;transition:background-color .2s,border-color .2s}
.wz-tick svg{width:10px;height:10px;fill:none;stroke:#fff;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round;opacity:0}
.wz-rules [data-met="true"]{color:var(--wz-ok)}
.wz-rules [data-met="true"] .wz-tick{background:var(--wz-ok);border-color:var(--wz-ok)}
.wz-rules [data-met="true"] svg{opacity:1}

/* checkboxes: a styled native checkbox with a tick drawn by the sibling svg */
.wz-check{display:flex;align-items:flex-start;gap:10px;font-size:.92rem}
.wz-check > div{display:grid;gap:4px}
.wz-box{position:relative;flex:none;width:20px;height:20px;margin-top:1px}
.wz-box input{appearance:none;margin:0;width:20px;height:20px;border:1.5px solid var(--wz-field);border-radius:6px;background:#fff;cursor:pointer;transition:background-color .2s,border-color .2s}
.wz-box input:checked{background:var(--wz-accent);border-color:var(--wz-accent)}
.wz-box input:focus-visible{outline:2px solid var(--wz-accent);outline-offset:2px}
.wz-box input[aria-invalid="true"]{border-color:var(--wz-err);background:var(--wz-err-soft)}
.wz-box svg{display:none;position:absolute;left:3px;top:3px;width:14px;height:14px;fill:none;stroke:#fff;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}
.wz-box input:checked + svg{display:block}
.wz-check label{cursor:pointer}

/* review list */
.wz-review{margin:0;display:grid;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden}
.wz-review div{display:flex;justify-content:space-between;gap:16px;padding:10px 14px;font-size:.92rem}
.wz-review div + div{border-top:1px solid #e2e8f0}
.wz-review dt{color:var(--wz-muted)}
.wz-review dd{margin:0;font-weight:600;text-align:right;overflow-wrap:anywhere}

/* resend line (passwordless) */
.wz-resend{margin:-4px 0 0;font-size:.88rem;color:var(--wz-muted)}
.wz-link{background:none;border:0;padding:2px;margin:0;font:inherit;font-weight:600;color:var(--wz-accent);text-decoration:underline;text-underline-offset:3px;border-radius:4px;cursor:pointer}
.wz-link:focus-visible{outline:2px solid var(--wz-accent);outline-offset:2px}
.wz-link[aria-disabled="true"]{color:var(--wz-muted);text-decoration:none;cursor:default}
.wz-note{display:block;color:var(--wz-ok);font-weight:600}

/* Back / Continue bar */
.wz-nav{display:flex;gap:10px;margin-top:28px;padding-top:20px;border-top:1px solid #e2e8f0}
.wz-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:0 22px;border-radius:10px;border:1.5px solid var(--wz-accent);background:var(--wz-accent);color:#fff;font:inherit;font-size:.95rem;font-weight:600;cursor:pointer;transition:background-color .2s,box-shadow .2s}
.wz-btn:hover{background:var(--wz-accent-dark)}
.wz-btn:focus-visible{outline:none;box-shadow:0 0 0 3px #fff,0 0 0 5px var(--wz-accent)}
.wz-btn.ghost{background:#fff;color:var(--wz-accent-dark);border-color:var(--wz-field)}
.wz-btn.ghost:hover{background:#eff6ff}
.wz-nav .wz-btn:not(.ghost){margin-left:auto}

/* compact card: full-width primary button, "Use a different email" as a text button under it */
.wz-compact{padding:28px}
.wz-compact .wz-nav{flex-direction:column;margin-top:22px;padding-top:0;border-top:0}
.wz-compact .wz-nav .wz-btn{width:100%;margin-left:0}
.wz-compact .wz-nav .wz-link{align-self:center;padding:6px 4px}

/* done panel */
.wz-done{text-align:center;justify-items:center;padding:12px 0 4px}
.wz-done .wz-lead{margin:0}
.wz-badge{width:56px;height:56px;border-radius:50%;display:grid;place-items:center;background:#ecfdf5;color:var(--wz-ok)}
.wz-badge svg{width:26px;height:26px;fill:none;stroke:currentColor;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}
@media (prefers-reduced-motion:reduce){.wz *,.wz *::before{transition:none!important;animation:none!important}}
@media (max-width:600px){.wz,.wz-compact{padding:24px 18px}}
JavaScript
// Signup wizard: one step at a time, stepper marked with data-state and aria-current="step", focus on the new
// step's heading. Validation uses the native constraints (required, type, pattern, minlength, setCustomValidity)
// but shows its own messages: each field's #<id>-error gets a specific text, aria-invalid is set and focus
// goes to the first invalid field. Flagged fields re-check as the user types, so the message clears when fixed.
document.querySelectorAll('[data-wizard]').forEach(function (wz) {
  var panels = wz.querySelectorAll('[data-step]'), steps = wz.querySelectorAll('[data-stepper] > li');
  var back = wz.querySelector('[data-back]'), next = wz.querySelector('[data-next]');
  var submit = wz.querySelector('[type="submit"]'), done = wz.querySelector('[data-done]');
  var summary = wz.querySelector('[data-summary]'), resend = wz.querySelector('[data-resend]'), at = 0;
  function mark(i) {
    steps.forEach(function (s, n) {
      s.dataset.state = n < i ? 'done' : n === i ? 'current' : 'next';
      if (n === i) s.setAttribute('aria-current', 'step'); else s.removeAttribute('aria-current');
      s.querySelector('[data-sr]').textContent = n < i ? ', completed' : '';
    });
  }
  function go(i, focus) {
    at = i; mark(i); say('');
    panels.forEach(function (p, n) { p.hidden = n !== i; });
    back.hidden = i === 0; next.hidden = i === panels.length - 1; submit.hidden = !next.hidden;
    wz.querySelectorAll('[data-out]').forEach(function (o) {
      var f = wz.elements[o.dataset.out];
      o.textContent = (f && f.value) || 'None';
    });
    if (focus) panels[i].querySelector('[tabindex="-1"]').focus();
  }
  // The summary is a polite live region; only write when the text changes so it is not re-announced.
  function say(t) { if (summary && summary.textContent !== t) summary.textContent = t; }
  function tally() {
    var n = panels[at].querySelectorAll('[aria-invalid]').length;
    say(n ? n + (n > 1 ? ' fields need' : ' field needs') + ' attention' : '');
  }
  // One field: pick the data-msg-* text for the failed constraint, else fall back to the browser's message.
  function check(f) {
    var v = f.validity, out = document.getElementById(f.id + '-error');
    var key = v.valueMissing ? 'msgRequired' : v.typeMismatch ? 'msgType' : v.tooShort ? 'msgShort' : v.patternMismatch ? 'msgPattern' : '';
    if (v.valid) f.removeAttribute('aria-invalid'); else f.setAttribute('aria-invalid', 'true');
    if (out) out.textContent = v.valid ? '' : f.dataset[key] || f.validationMessage;
    return v.valid;
  }
  function valid() {
    var bad = [].filter.call(panels[at].querySelectorAll('input, select, textarea'), function (f) { return !check(f); });
    tally();
    if (bad[0]) bad[0].focus();
    return !bad.length;
  }
  // Live rules: [data-match="<id>"] must equal that field (custom validity); every <li data-rule="regex">
  // inside [data-rules="<id>"] gets data-met="true|false" and a hidden ", done" for screen readers.
  function live() {
    wz.querySelectorAll('[data-match]').forEach(function (c) {
      var o = document.getElementById(c.dataset.match);
      c.setCustomValidity(o && c.value && c.value !== o.value ? c.dataset.msgMatch || 'The values do not match.' : '');
    });
    wz.querySelectorAll('[data-rules]').forEach(function (list) {
      var f = document.getElementById(list.dataset.rules);
      list.querySelectorAll('[data-rule]').forEach(function (r) {
        var ok = !!f && new RegExp(r.dataset.rule).test(f.value);
        r.dataset.met = ok; r.querySelector('[data-sr]').textContent = ok ? ', done' : '';
      });
    });
  }
  wz.noValidate = true;
  wz.addEventListener('input', function () {
    live();
    panels[at].querySelectorAll('[aria-invalid]').forEach(check);
    tally();
  });
  back.addEventListener('click', function () { go(at - 1, true); });
  next.addEventListener('click', function () { if (valid()) go(at + 1, true); });
  wz.addEventListener('submit', function (e) {
    e.preventDefault();
    if (!valid()) return;
    if (at < panels.length - 1) return go(at + 1, true);
    // Last step: send the form here (fetch or wz.submit()). The demo shows the done panel instead.
    panels[at].hidden = true; back.parentNode.hidden = true; mark(steps.length);
    done.hidden = false; done.querySelector('[tabindex="-1"]').focus();
  });
  if (done) done.querySelector('[data-restart]').addEventListener('click', function () {
    wz.reset(); live(); done.hidden = true; back.parentNode.hidden = false; go(0, true);
  });
  // Resend: a polite "New code sent", then a 30 s cool-down in the button text. aria-disabled (not disabled)
  // keeps keyboard focus on the button while it counts down.
  if (resend) resend.addEventListener('click', function () {
    if (resend.getAttribute('aria-disabled')) return;
    var label = resend.textContent, left = 30, note = wz.querySelector('[data-resend-msg]');
    if (note) { note.textContent = ''; setTimeout(function () { note.textContent = 'New code sent.'; }, 60); }
    resend.setAttribute('aria-disabled', 'true');
    (function tick() {
      if (left) { resend.textContent = 'Resend in ' + left-- + ' s'; setTimeout(tick, 1000); }
      else { resend.textContent = label; resend.removeAttribute('aria-disabled'); }
    })();
  });
  live(); go(0);
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Create an account, inline errors</span>
<form class="wz card" data-wizard aria-labelledby="la-title"><div class="card-body">
  <div class="wz-brand"><span class="wz-mark"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14h9"/></svg></span>Ledgerly</div>
  <h2 class="wz-title fw-bold mb-0" id="la-title">Create your account</h2>
  <p class="wz-sub text-body-secondary mt-1 mb-0">Free for 30 days, then $9 per user a month. No card needed.</p>
  <ol class="wz-track" data-stepper aria-label="Progress">
    <li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Account<span class="visually-hidden" data-sr></span></li>
    <li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Profile<span class="visually-hidden" data-sr></span></li>
    <li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Confirm<span class="visually-hidden" data-sr></span></li>
  </ol>
  <p class="wz-summary" data-summary aria-live="polite"></p>

  <section class="wz-panel" data-step aria-labelledby="la-h1">
    <h3 class="wz-h fw-bold mb-0" id="la-h1" tabindex="-1"><span class="wz-kick">Step 1 of 3</span>Your login details</h3>
    <div><label class="form-label" for="la-email">Work email</label>
      <input class="form-control" id="la-email" name="email" type="email" required autocomplete="email" placeholder="[email protected]" aria-describedby="la-email-error"
        data-msg-required="Enter your work email." data-msg-type="Enter an email address like [email protected].">
      <p class="wz-error" id="la-email-error"></p></div>
    <div><label class="form-label" for="la-pw">Password</label>
      <input class="form-control" id="la-pw" name="password" type="password" required minlength="10" pattern="(?=.*\d)(?=.*[A-Z]).{10,}" autocomplete="new-password" aria-describedby="la-pw-rules la-pw-error"
        data-msg-required="Create a password." data-msg-short="Use at least 10 characters." data-msg-pattern="Use 10 or more characters with at least one number and one uppercase letter.">
      <ul class="wz-rules list-unstyled" id="la-pw-rules" data-rules="la-pw">
        <li data-rule=".{10,}"><span class="wz-tick"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>At least 10 characters<span class="visually-hidden" data-sr></span></li>
        <li data-rule="\d"><span class="wz-tick"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>A number<span class="visually-hidden" data-sr></span></li>
        <li data-rule="[A-Z]"><span class="wz-tick"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>An uppercase letter<span class="visually-hidden" data-sr></span></li>
      </ul>
      <p class="wz-error" id="la-pw-error"></p></div>
    <div><label class="form-label" for="la-pw2">Confirm password</label>
      <input class="form-control" id="la-pw2" name="confirm" type="password" required autocomplete="new-password" data-match="la-pw" aria-describedby="la-pw2-error"
        data-msg-required="Type your password again." data-msg-match="The passwords don't match.">
      <p class="wz-error" id="la-pw2-error"></p></div>
  </section>

  <section class="wz-panel" data-step aria-labelledby="la-h2">
    <h3 class="wz-h fw-bold mb-0" id="la-h2" tabindex="-1"><span class="wz-kick">Step 2 of 3</span>About you</h3>
    <div class="row g-3">
      <div class="col-sm-6"><label class="form-label" for="la-name">Full name</label>
        <input class="form-control" id="la-name" name="fullname" required autocomplete="name" placeholder="Dana Whitfield" aria-describedby="la-name-error" data-msg-required="Enter your full name.">
        <p class="wz-error" id="la-name-error"></p></div>
      <div class="col-sm-6"><label class="form-label" for="la-user">Username</label>
        <input class="form-control" id="la-user" name="username" required pattern="^[a-z0-9_]{3,20}$" autocomplete="username" autocapitalize="none" spellcheck="false" placeholder="dana_w" aria-describedby="la-user-hint la-user-error"
          data-msg-required="Choose a username." data-msg-pattern="Use 3 to 20 lowercase letters, numbers or underscores.">
        <p class="form-text" id="la-user-hint">3–20 lowercase letters, numbers or _</p>
        <p class="wz-error" id="la-user-error"></p></div>
    </div>
    <div><label class="form-label" for="la-country">Country</label>
      <select class="form-select" id="la-country" name="country" required autocomplete="country-name" aria-describedby="la-country-error" data-msg-required="Choose the country your business is based in.">
        <option value="">Choose a country</option><option>Australia</option><option>Canada</option><option>Germany</option><option>Ireland</option><option>New Zealand</option><option>United Kingdom</option><option>United States</option>
      </select>
      <p class="wz-error" id="la-country-error"></p></div>
  </section>

  <section class="wz-panel" data-step aria-labelledby="la-h3">
    <h3 class="wz-h fw-bold mb-0" id="la-h3" tabindex="-1"><span class="wz-kick">Step 3 of 3</span>Check and confirm</h3>
    <dl class="list-group mb-0">
      <div class="list-group-item d-flex justify-content-between gap-3"><dt>Email</dt><dd data-out="email">None</dd></div>
      <div class="list-group-item d-flex justify-content-between gap-3"><dt>Name</dt><dd data-out="fullname">None</dd></div>
      <div class="list-group-item d-flex justify-content-between gap-3"><dt>Username</dt><dd data-out="username">None</dd></div>
      <div class="list-group-item d-flex justify-content-between gap-3"><dt>Country</dt><dd data-out="country">None</dd></div>
    </dl>
    <div class="form-check"><input class="form-check-input" type="checkbox" id="la-terms" name="terms" required aria-describedby="la-terms-error" data-msg-required="Accept the terms to create your account.">
      <label class="form-check-label" for="la-terms">I agree to the Ledgerly Terms of Service and Privacy Policy</label><p class="wz-error" id="la-terms-error"></p></div>
    <div class="form-check"><input class="form-check-input" type="checkbox" id="la-news" name="updates">
      <label class="form-check-label" for="la-news">Email me product updates, about twice a month (optional)</label></div>
  </section>

  <section class="wz-panel wz-done" data-done hidden aria-labelledby="la-h4">
    <span class="wz-badge"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5.5" width="17" height="13" rx="2"/><path d="m4 7 8 6 8-6"/></svg></span>
    <h3 class="wz-h fw-bold mb-0" id="la-h4" tabindex="-1">Check your inbox</h3>
    <p class="wz-lead">We sent a confirmation link to <b data-out="email">your email</b>. Open it within 24 hours to activate your account.</p>
    <button type="button" class="btn btn-outline-secondary" data-restart>Start over</button>
  </section>

  <div class="wz-nav">
    <button type="button" class="btn btn-outline-secondary" data-back hidden>Back</button>
    <button type="button" class="btn btn-primary" data-next hidden>Continue</button>
    <button type="submit" class="btn btn-primary">Create account</button>
  </div>
</div></form></div>

<div class="narrow"><span class="lbl">Passwordless sign-in</span>
<form class="wz wz-compact card" data-wizard aria-labelledby="pl-title"><div class="card-body">
  <div class="wz-brand"><span class="wz-mark"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14h9"/></svg></span>Ledgerly</div>
  <h2 class="wz-title fw-bold mb-0" id="pl-title">Sign in to Ledgerly</h2>
  <p class="wz-sub text-body-secondary mt-1 mb-0">No password needed. We email you a one-time code.</p>
  <ol class="wz-track" data-stepper aria-label="Progress">
    <li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Email<span class="visually-hidden" data-sr></span></li>
    <li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Code<span class="visually-hidden" data-sr></span></li>
  </ol>

  <section class="wz-panel" data-step aria-labelledby="pl-h1">
    <h3 class="wz-h fw-bold mb-0" id="pl-h1" tabindex="-1"><span class="wz-kick">Step 1 of 2</span>What's your email?</h3>
    <div><label class="form-label" for="pl-email">Work email</label>
      <input class="form-control" id="pl-email" name="email" type="email" required autocomplete="email" placeholder="[email protected]" aria-describedby="pl-email-error"
        data-msg-required="Enter the email you use for Ledgerly." data-msg-type="Enter an email address like [email protected].">
      <p class="wz-error" id="pl-email-error"></p></div>
  </section>

  <section class="wz-panel" data-step aria-labelledby="pl-h2">
    <h3 class="wz-h fw-bold mb-0" id="pl-h2" tabindex="-1"><span class="wz-kick">Step 2 of 2</span>Enter your code</h3>
    <p class="wz-lead">We sent a 6-digit code to <b data-out="email">your email</b>.</p>
    <div><label class="form-label" for="pl-code">6-digit code</label>
      <input class="form-control wz-code" id="pl-code" name="code" required inputmode="numeric" autocomplete="one-time-code" pattern="\d{6}" maxlength="6" aria-describedby="pl-code-hint pl-code-error"
        data-msg-required="Enter the 6-digit code from the email." data-msg-pattern="The code is 6 digits, numbers only.">
      <p class="form-text" id="pl-code-hint">The code expires in 10 minutes.</p>
      <p class="wz-error" id="pl-code-error"></p></div>
    <p class="wz-resend">Didn't get it? <button type="button" class="btn btn-link" data-resend>Resend code</button><span class="wz-note" data-resend-msg aria-live="polite"></span></p>
  </section>

  <section class="wz-panel wz-done" data-done hidden aria-labelledby="pl-h3">
    <span class="wz-badge"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
    <h3 class="wz-h fw-bold mb-0" id="pl-h3" tabindex="-1">You're signed in</h3>
    <p class="wz-lead">Welcome back, <b data-out="email">you</b>. Your books are ready where you left them.</p>
    <button type="button" class="btn btn-outline-secondary" data-restart>Sign out</button>
  </section>

  <div class="wz-nav">
    <button type="button" class="btn btn-primary" data-next hidden>Email me a code</button>
    <button type="submit" class="btn btn-primary">Sign in</button>
    <button type="button" class="btn btn-link" data-back hidden>Use a different email</button>
  </div>
</div></form></div>
CSS
/* Signup wizard on Bootstrap 5: .card, .form-control, .form-select, .form-check, .form-text, .list-group and
   .btn, restyled through Bootstrap's CSS variables. Native constraints (required, type, pattern, minlength and
   setCustomValidity) decide what is wrong; the script writes a specific message into each field's #<id>-error
   and sets aria-invalid="true", which the rules below style (red border plus icon and text), so no
   .is-invalid classes are needed. Without JavaScript every step shows and the browser validates on submit. */
.wz{--wz-accent:#1d4ed8;--wz-accent-dark:#1e3a8a;--wz-soft:#dbeafe;--wz-err:#b91c1c;--wz-err-soft:#fef2f2;--wz-ok:#047857;
  --wz-muted:#475569;--wz-line:#cbd5e1;
  --bs-border-color:#64748b;--bs-border-radius:10px;--bs-body-color:#0f172a;--bs-secondary-color:#475569;
  border:0;border-radius:18px;box-shadow:0 1px 2px rgba(15,23,42,.06),0 12px 32px -14px rgba(30,58,138,.22)}
.wz .card-body{padding:30px 32px}
.wz .form-control,.wz .form-select{border-width:1.5px;min-height:44px;padding:10px 12px;font-size:.95rem;transition:border-color .2s,box-shadow .2s}
.wz .form-select{padding-right:36px}
.wz .form-control::placeholder{color:#64748b}
.wz .form-control:focus,.wz .form-select:focus{border-color:var(--wz-accent);box-shadow:0 0 0 4px var(--wz-soft)}
.wz .form-control[aria-invalid="true"],.wz .form-select[aria-invalid="true"]{border-color:var(--wz-err)}
.wz .form-control[aria-invalid="true"]:focus,.wz .form-select[aria-invalid="true"]:focus{box-shadow:0 0 0 4px #fecaca}
.wz .form-label{font-size:.88rem;font-weight:600;margin-bottom:6px}
.wz .form-text{font-size:.8rem;color:var(--wz-muted);margin:6px 0 0}
.wz .form-check{padding-left:30px;margin:0;font-size:.92rem}
.wz .form-check-input{width:20px;height:20px;margin:1px 0 0 -30px;border-width:1.5px;border-color:#64748b;border-radius:6px;transition:background-color .2s,border-color .2s}
.wz .form-check-input:checked{background-color:var(--wz-accent);border-color:var(--wz-accent)}
.wz .form-check-input:focus{border-color:var(--wz-accent);box-shadow:none}
.wz .form-check-input:focus-visible{outline:2px solid var(--wz-accent);outline-offset:2px}
.wz .form-check-input[aria-invalid="true"]{border-color:var(--wz-err);background-color:var(--wz-err-soft)}
.wz .btn{--bs-btn-padding-x:22px;--bs-btn-font-weight:600;--bs-btn-border-width:1.5px;min-height:44px}
.wz .btn-primary{--bs-btn-bg:var(--wz-accent);--bs-btn-border-color:var(--wz-accent);--bs-btn-hover-bg:var(--wz-accent-dark);--bs-btn-hover-border-color:var(--wz-accent-dark);--bs-btn-active-bg:var(--wz-accent-dark);--bs-btn-active-border-color:var(--wz-accent-dark)}
.wz .btn-outline-secondary{--bs-btn-color:var(--wz-accent-dark);--bs-btn-border-color:#64748b;--bs-btn-hover-bg:#eff6ff;--bs-btn-hover-color:var(--wz-accent-dark);--bs-btn-hover-border-color:#64748b;--bs-btn-active-bg:#dbeafe;--bs-btn-active-color:var(--wz-accent-dark);--bs-btn-bg:#fff}
.wz .btn:focus-visible{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--wz-accent);outline:0}
.wz .list-group{--bs-list-group-border-color:#e2e8f0;--bs-list-group-border-radius:12px;--bs-list-group-item-padding-y:10px;font-size:.92rem}
.wz .list-group dt{font-weight:400;color:var(--wz-muted)}
.wz .list-group dd{margin:0;font-weight:600;text-align:right;overflow-wrap:anywhere}
.wz-brand{display:flex;align-items:center;gap:10px;margin-bottom:18px;font-weight:700;letter-spacing:-.01em}
.wz-mark{width:30px;height:30px;border-radius:9px;background:var(--wz-accent);display:grid;place-items:center}
.wz-mark svg{width:16px;height:16px;fill:none;stroke:#fff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
.wz-title{font-size:1.3rem;letter-spacing:-.015em;line-height:1.5}
.wz-sub{font-size:.92rem}

/* stepper: one bar per step; the current bar is half filled, finished ones fill and get a tick */
.wz-track{list-style:none;margin:24px 0 0;padding:0;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:8px}
.wz-track li{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:.8rem;color:var(--wz-muted)}
.wz-track li::before{content:"";flex:0 0 100%;height:4px;margin-bottom:4px;border-radius:4px;background:var(--wz-line);transition:background-color .25s}
.wz-track svg{display:none;width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
.wz-track [data-state="current"]{color:#0f172a;font-weight:600}
.wz-track [data-state="current"]::before{background:linear-gradient(90deg,var(--wz-accent) 50%,var(--wz-line) 50%)}
.wz-track [data-state="done"]{color:var(--wz-accent-dark);font-weight:600}
.wz-track [data-state="done"]::before{background:var(--wz-accent)}
.wz-track [data-state="done"] svg{display:block}

/* error summary: a polite live region that stays in the page and only draws a box when it has text */
.wz-summary{margin:0;font-size:.88rem;font-weight:600;color:var(--wz-err)}
.wz-summary:not(:empty){display:flex;align-items:center;gap:9px;margin-top:22px;padding:10px 14px;border:1px solid #fecaca;border-radius:10px;background:var(--wz-err-soft)}

/* step panels */
.wz-panel{margin-top:26px;display:grid;gap:18px;animation:wz-in .3s ease}
@keyframes wz-in{from{opacity:0;transform:translateY(6px)}}
.wz-h{font-size:1.15rem;letter-spacing:-.01em;line-height:1.5}
.wz-h:focus{outline:none}
.wz-kick{display:block;font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;color:var(--wz-accent-dark);margin-bottom:2px}
.wz-lead{margin:-10px 0 0;color:var(--wz-muted);font-size:.92rem}
.wz-lead b{color:#0f172a;overflow-wrap:anywhere}
.wz-code{min-height:58px!important;font-family:var(--bs-font-monospace);font-size:1.6rem!important;font-weight:700;letter-spacing:.5em;text-indent:.5em;text-align:center}

/* inline error: empty until the script writes a message; the "!" badge is decorative (alt text "") */
.wz-error{display:flex;align-items:flex-start;gap:7px;margin:6px 0 0;font-size:.84rem;font-weight:500;color:var(--wz-err)}
.wz-error:empty{display:none}
.wz-error::before,.wz-summary:not(:empty)::before{content:"!";content:"!" / "";flex:none;display:grid;place-items:center;width:17px;height:17px;margin-top:1px;border-radius:50%;background:var(--wz-err);color:#fff;font-size:.72rem;font-weight:800;line-height:1}
.form-check .wz-error{margin-top:4px}

/* password checklist: each rule ticks green as it is met */
.wz-rules{display:flex;flex-wrap:wrap;gap:6px 16px;margin:6px 0 0;font-size:.8rem;color:var(--wz-muted)}
.wz-rules li{display:flex;align-items:center;gap:6px;transition:color .2s}
.wz-tick{flex:none;width:16px;height:16px;border-radius:50%;border:1.5px solid #64748b;display:grid;place-items:center;transition:background-color .2s,border-color .2s}
.wz-tick svg{width:10px;height:10px;fill:none;stroke:#fff;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round;opacity:0}
.wz-rules [data-met="true"]{color:var(--wz-ok)}
.wz-rules [data-met="true"] .wz-tick{background:var(--wz-ok);border-color:var(--wz-ok)}
.wz-rules [data-met="true"] svg{opacity:1}

/* resend line (passwordless) */
.wz-resend{margin:-4px 0 0;font-size:.88rem;color:var(--wz-muted)}
.wz .btn-link{--bs-btn-color:var(--wz-accent);--bs-btn-hover-color:var(--wz-accent-dark);--bs-btn-active-color:var(--wz-accent-dark);--bs-btn-disabled-color:var(--wz-muted);font-weight:600;text-underline-offset:3px}
.wz-resend .btn-link{--bs-btn-padding-x:2px;--bs-btn-padding-y:2px;min-height:0;font-size:inherit;vertical-align:baseline;border:0}
.wz .btn-link[aria-disabled="true"]{color:var(--wz-muted);text-decoration:none;cursor:default}
.wz-note{display:block;color:var(--wz-ok);font-weight:600}

/* Back / Continue bar */
.wz-nav{display:flex;gap:10px;margin-top:28px;padding-top:20px;border-top:1px solid #e2e8f0}
.wz-nav .btn-primary{margin-left:auto}

/* compact card: full-width primary button, "Use a different email" as a text button under it */
.wz-compact .card-body{padding:28px}
.wz-compact .wz-nav{flex-direction:column;margin-top:22px;padding-top:0;border-top:0}
.wz-compact .wz-nav .btn-primary{width:100%;margin-left:0}
.wz-compact .wz-nav .btn-link{align-self:center;--bs-btn-padding-x:4px;--bs-btn-padding-y:6px;min-height:0;font-size:.95rem}

/* done panel */
.wz-done{text-align:center;justify-items:center;padding:12px 0 4px}
.wz-done .wz-lead{margin:0}
.wz-badge{width:56px;height:56px;border-radius:50%;display:grid;place-items:center;background:#ecfdf5;color:var(--wz-ok)}
.wz-badge svg{width:26px;height:26px;fill:none;stroke:currentColor;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}
@media (prefers-reduced-motion:reduce){.wz *,.wz *::before{transition:none!important;animation:none!important}}
@media (max-width:600px){.wz .card-body,.wz-compact .card-body{padding:24px 18px}}
JavaScript
// Signup wizard: one step at a time, stepper marked with data-state and aria-current="step", focus on the new
// step's heading. Validation uses the native constraints (required, type, pattern, minlength, setCustomValidity)
// but shows its own messages: each field's #<id>-error gets a specific text, aria-invalid is set and focus
// goes to the first invalid field. Flagged fields re-check as the user types, so the message clears when fixed.
document.querySelectorAll('[data-wizard]').forEach(function (wz) {
  var panels = wz.querySelectorAll('[data-step]'), steps = wz.querySelectorAll('[data-stepper] > li');
  var back = wz.querySelector('[data-back]'), next = wz.querySelector('[data-next]');
  var submit = wz.querySelector('[type="submit"]'), done = wz.querySelector('[data-done]');
  var summary = wz.querySelector('[data-summary]'), resend = wz.querySelector('[data-resend]'), at = 0;
  function mark(i) {
    steps.forEach(function (s, n) {
      s.dataset.state = n < i ? 'done' : n === i ? 'current' : 'next';
      if (n === i) s.setAttribute('aria-current', 'step'); else s.removeAttribute('aria-current');
      s.querySelector('[data-sr]').textContent = n < i ? ', completed' : '';
    });
  }
  function go(i, focus) {
    at = i; mark(i); say('');
    panels.forEach(function (p, n) { p.hidden = n !== i; });
    back.hidden = i === 0; next.hidden = i === panels.length - 1; submit.hidden = !next.hidden;
    wz.querySelectorAll('[data-out]').forEach(function (o) {
      var f = wz.elements[o.dataset.out];
      o.textContent = (f && f.value) || 'None';
    });
    if (focus) panels[i].querySelector('[tabindex="-1"]').focus();
  }
  // The summary is a polite live region; only write when the text changes so it is not re-announced.
  function say(t) { if (summary && summary.textContent !== t) summary.textContent = t; }
  function tally() {
    var n = panels[at].querySelectorAll('[aria-invalid]').length;
    say(n ? n + (n > 1 ? ' fields need' : ' field needs') + ' attention' : '');
  }
  // One field: pick the data-msg-* text for the failed constraint, else fall back to the browser's message.
  function check(f) {
    var v = f.validity, out = document.getElementById(f.id + '-error');
    var key = v.valueMissing ? 'msgRequired' : v.typeMismatch ? 'msgType' : v.tooShort ? 'msgShort' : v.patternMismatch ? 'msgPattern' : '';
    if (v.valid) f.removeAttribute('aria-invalid'); else f.setAttribute('aria-invalid', 'true');
    if (out) out.textContent = v.valid ? '' : f.dataset[key] || f.validationMessage;
    return v.valid;
  }
  function valid() {
    var bad = [].filter.call(panels[at].querySelectorAll('input, select, textarea'), function (f) { return !check(f); });
    tally();
    if (bad[0]) bad[0].focus();
    return !bad.length;
  }
  // Live rules: [data-match="<id>"] must equal that field (custom validity); every <li data-rule="regex">
  // inside [data-rules="<id>"] gets data-met="true|false" and a hidden ", done" for screen readers.
  function live() {
    wz.querySelectorAll('[data-match]').forEach(function (c) {
      var o = document.getElementById(c.dataset.match);
      c.setCustomValidity(o && c.value && c.value !== o.value ? c.dataset.msgMatch || 'The values do not match.' : '');
    });
    wz.querySelectorAll('[data-rules]').forEach(function (list) {
      var f = document.getElementById(list.dataset.rules);
      list.querySelectorAll('[data-rule]').forEach(function (r) {
        var ok = !!f && new RegExp(r.dataset.rule).test(f.value);
        r.dataset.met = ok; r.querySelector('[data-sr]').textContent = ok ? ', done' : '';
      });
    });
  }
  wz.noValidate = true;
  wz.addEventListener('input', function () {
    live();
    panels[at].querySelectorAll('[aria-invalid]').forEach(check);
    tally();
  });
  back.addEventListener('click', function () { go(at - 1, true); });
  next.addEventListener('click', function () { if (valid()) go(at + 1, true); });
  wz.addEventListener('submit', function (e) {
    e.preventDefault();
    if (!valid()) return;
    if (at < panels.length - 1) return go(at + 1, true);
    // Last step: send the form here (fetch or wz.submit()). The demo shows the done panel instead.
    panels[at].hidden = true; back.parentNode.hidden = true; mark(steps.length);
    done.hidden = false; done.querySelector('[tabindex="-1"]').focus();
  });
  if (done) done.querySelector('[data-restart]').addEventListener('click', function () {
    wz.reset(); live(); done.hidden = true; back.parentNode.hidden = false; go(0, true);
  });
  // Resend: a polite "New code sent", then a 30 s cool-down in the button text. aria-disabled (not disabled)
  // keeps keyboard focus on the button while it counts down.
  if (resend) resend.addEventListener('click', function () {
    if (resend.getAttribute('aria-disabled')) return;
    var label = resend.textContent, left = 30, note = wz.querySelector('[data-resend-msg]');
    if (note) { note.textContent = ''; setTimeout(function () { note.textContent = 'New code sent.'; }, 60); }
    resend.setAttribute('aria-disabled', 'true');
    (function tick() {
      if (left) { resend.textContent = 'Resend in ' + left-- + ' s'; setTimeout(tick, 1000); }
      else { resend.textContent = label; resend.removeAttribute('aria-disabled'); }
    })();
  });
  live(); go(0);
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-wz-muted uppercase">Create an account, inline errors</span>
<form class="rounded-[18px] bg-white px-8 py-[30px] text-wz-ink shadow-[0_1px_2px_rgba(15,23,42,.06),0_12px_32px_-14px_rgba(30,58,138,.22)] max-[600px]:px-[18px] max-[600px]:py-6" data-wizard aria-labelledby="la-title">
  <div class="mb-[18px] flex items-center gap-2.5 font-bold tracking-[-.01em]"><span class="grid size-[30px] place-items-center rounded-[9px] bg-wz-accent"><svg class="size-4 fill-none stroke-white stroke-3" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14h9"/></svg></span>Ledgerly</div>
  <h2 class="text-[1.3rem] font-bold tracking-[-.015em]" id="la-title">Create your account</h2>
  <p class="mt-1 text-[.92rem] text-wz-muted">Free for 30 days, then $9 per user a month. No card needed.</p>
  <ol class="mt-6 grid auto-cols-fr grid-flow-col gap-2" data-stepper aria-label="Progress">
    <li class="group flex flex-wrap items-center gap-1.5 text-[.8rem] text-wz-muted before:mb-1 before:h-1 before:basis-full before:rounded before:bg-wz-line before:transition-colors data-[state=current]:font-semibold data-[state=current]:text-wz-ink data-[state=current]:before:bg-[linear-gradient(90deg,var(--color-wz-accent)_50%,var(--color-wz-line)_50%)] data-[state=done]:font-semibold data-[state=done]:text-wz-dark data-[state=done]:before:bg-wz-accent motion-reduce:before:transition-none"><svg class="hidden size-3.5 fill-none stroke-current stroke-3 group-data-[state=done]:block" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Account<span class="sr-only" data-sr></span></li>
    <li class="group flex flex-wrap items-center gap-1.5 text-[.8rem] text-wz-muted before:mb-1 before:h-1 before:basis-full before:rounded before:bg-wz-line before:transition-colors data-[state=current]:font-semibold data-[state=current]:text-wz-ink data-[state=current]:before:bg-[linear-gradient(90deg,var(--color-wz-accent)_50%,var(--color-wz-line)_50%)] data-[state=done]:font-semibold data-[state=done]:text-wz-dark data-[state=done]:before:bg-wz-accent motion-reduce:before:transition-none"><svg class="hidden size-3.5 fill-none stroke-current stroke-3 group-data-[state=done]:block" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Profile<span class="sr-only" data-sr></span></li>
    <li class="group flex flex-wrap items-center gap-1.5 text-[.8rem] text-wz-muted before:mb-1 before:h-1 before:basis-full before:rounded before:bg-wz-line before:transition-colors data-[state=current]:font-semibold data-[state=current]:text-wz-ink data-[state=current]:before:bg-[linear-gradient(90deg,var(--color-wz-accent)_50%,var(--color-wz-line)_50%)] data-[state=done]:font-semibold data-[state=done]:text-wz-dark data-[state=done]:before:bg-wz-accent motion-reduce:before:transition-none"><svg class="hidden size-3.5 fill-none stroke-current stroke-3 group-data-[state=done]:block" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Confirm<span class="sr-only" data-sr></span></li>
  </ol>
  <p class="text-[.88rem] font-semibold text-wz-err not-empty:mt-[22px] not-empty:flex not-empty:items-center not-empty:gap-[9px] not-empty:rounded-[10px] not-empty:border not-empty:border-[#fecaca] not-empty:bg-wz-err-soft not-empty:px-3.5 not-empty:py-2.5 not-empty:before:mt-px not-empty:before:grid not-empty:before:size-[17px] not-empty:before:flex-none not-empty:before:place-items-center not-empty:before:rounded-full not-empty:before:bg-wz-err not-empty:before:text-[.72rem] not-empty:before:leading-none not-empty:before:font-extrabold not-empty:before:text-white not-empty:before:wz-bang" data-summary aria-live="polite"></p>

  <section class="mt-[26px] grid animate-wz-in gap-[18px] motion-reduce:animate-none" data-step aria-labelledby="la-h1">
    <h3 class="text-[1.15rem] font-bold tracking-[-.01em] focus:outline-none" id="la-h1" tabindex="-1"><span class="mb-0.5 block text-[.72rem] font-bold tracking-[.1em] text-wz-dark uppercase">Step 1 of 3</span>Your login details</h3>
    <div class="grid min-w-0 content-start gap-1.5"><label class="text-[.88rem] font-semibold" for="la-email">Work email</label>
      <input class="min-h-11 w-full rounded-[10px] border-[1.5px] border-wz-field bg-white px-3 py-2.5 transition-[border-color,box-shadow] placeholder:text-[#64748b] focus-visible:border-wz-accent focus-visible:shadow-[0_0_0_4px_var(--color-wz-soft)] focus-visible:outline-none aria-invalid:border-wz-err aria-invalid:focus-visible:border-wz-err aria-invalid:focus-visible:shadow-[0_0_0_4px_#fecaca] motion-reduce:transition-none text-[.95rem]" id="la-email" name="email" type="email" required autocomplete="email" placeholder="[email protected]" aria-describedby="la-email-error"
        data-msg-required="Enter your work email." data-msg-type="Enter an email address like [email protected].">
      <p class="flex items-start gap-[7px] text-[.84rem] font-medium text-wz-err empty:hidden before:mt-px before:grid before:size-[17px] before:flex-none before:place-items-center before:rounded-full before:bg-wz-err before:text-[.72rem] before:leading-none before:font-extrabold before:text-white before:wz-bang" id="la-email-error"></p></div>
    <div class="grid min-w-0 content-start gap-1.5"><label class="text-[.88rem] font-semibold" for="la-pw">Password</label>
      <input class="min-h-11 w-full rounded-[10px] border-[1.5px] border-wz-field bg-white px-3 py-2.5 transition-[border-color,box-shadow] placeholder:text-[#64748b] focus-visible:border-wz-accent focus-visible:shadow-[0_0_0_4px_var(--color-wz-soft)] focus-visible:outline-none aria-invalid:border-wz-err aria-invalid:focus-visible:border-wz-err aria-invalid:focus-visible:shadow-[0_0_0_4px_#fecaca] motion-reduce:transition-none text-[.95rem]" id="la-pw" name="password" type="password" required minlength="10" pattern="(?=.*\d)(?=.*[A-Z]).{10,}" autocomplete="new-password" aria-describedby="la-pw-rules la-pw-error"
        data-msg-required="Create a password." data-msg-short="Use at least 10 characters." data-msg-pattern="Use 10 or more characters with at least one number and one uppercase letter.">
      <ul class="flex flex-wrap gap-x-4 gap-y-1.5 text-[.8rem] text-wz-muted" id="la-pw-rules" data-rules="la-pw">
        <li class="group flex items-center gap-1.5 transition-colors data-[met=true]:text-wz-ok motion-reduce:transition-none" data-rule=".{10,}"><span class="grid size-4 flex-none place-items-center rounded-full border-[1.5px] border-wz-field transition-colors group-data-[met=true]:border-wz-ok group-data-[met=true]:bg-wz-ok motion-reduce:transition-none"><svg class="size-2.5 fill-none stroke-white stroke-[3.5] opacity-0 group-data-[met=true]:opacity-100" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>At least 10 characters<span class="sr-only" data-sr></span></li>
        <li class="group flex items-center gap-1.5 transition-colors data-[met=true]:text-wz-ok motion-reduce:transition-none" data-rule="\d"><span class="grid size-4 flex-none place-items-center rounded-full border-[1.5px] border-wz-field transition-colors group-data-[met=true]:border-wz-ok group-data-[met=true]:bg-wz-ok motion-reduce:transition-none"><svg class="size-2.5 fill-none stroke-white stroke-[3.5] opacity-0 group-data-[met=true]:opacity-100" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>A number<span class="sr-only" data-sr></span></li>
        <li class="group flex items-center gap-1.5 transition-colors data-[met=true]:text-wz-ok motion-reduce:transition-none" data-rule="[A-Z]"><span class="grid size-4 flex-none place-items-center rounded-full border-[1.5px] border-wz-field transition-colors group-data-[met=true]:border-wz-ok group-data-[met=true]:bg-wz-ok motion-reduce:transition-none"><svg class="size-2.5 fill-none stroke-white stroke-[3.5] opacity-0 group-data-[met=true]:opacity-100" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>An uppercase letter<span class="sr-only" data-sr></span></li>
      </ul>
      <p class="flex items-start gap-[7px] text-[.84rem] font-medium text-wz-err empty:hidden before:mt-px before:grid before:size-[17px] before:flex-none before:place-items-center before:rounded-full before:bg-wz-err before:text-[.72rem] before:leading-none before:font-extrabold before:text-white before:wz-bang" id="la-pw-error"></p></div>
    <div class="grid min-w-0 content-start gap-1.5"><label class="text-[.88rem] font-semibold" for="la-pw2">Confirm password</label>
      <input class="min-h-11 w-full rounded-[10px] border-[1.5px] border-wz-field bg-white px-3 py-2.5 transition-[border-color,box-shadow] placeholder:text-[#64748b] focus-visible:border-wz-accent focus-visible:shadow-[0_0_0_4px_var(--color-wz-soft)] focus-visible:outline-none aria-invalid:border-wz-err aria-invalid:focus-visible:border-wz-err aria-invalid:focus-visible:shadow-[0_0_0_4px_#fecaca] motion-reduce:transition-none text-[.95rem]" id="la-pw2" name="confirm" type="password" required autocomplete="new-password" data-match="la-pw" aria-describedby="la-pw2-error"
        data-msg-required="Type your password again." data-msg-match="The passwords don't match.">
      <p class="flex items-start gap-[7px] text-[.84rem] font-medium text-wz-err empty:hidden before:mt-px before:grid before:size-[17px] before:flex-none before:place-items-center before:rounded-full before:bg-wz-err before:text-[.72rem] before:leading-none before:font-extrabold before:text-white before:wz-bang" id="la-pw2-error"></p></div>
  </section>

  <section class="mt-[26px] grid animate-wz-in gap-[18px] motion-reduce:animate-none" data-step aria-labelledby="la-h2">
    <h3 class="text-[1.15rem] font-bold tracking-[-.01em] focus:outline-none" id="la-h2" tabindex="-1"><span class="mb-0.5 block text-[.72rem] font-bold tracking-[.1em] text-wz-dark uppercase">Step 2 of 3</span>About you</h3>
    <div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-[18px]">
      <div class="grid min-w-0 content-start gap-1.5"><label class="text-[.88rem] font-semibold" for="la-name">Full name</label>
        <input class="min-h-11 w-full rounded-[10px] border-[1.5px] border-wz-field bg-white px-3 py-2.5 transition-[border-color,box-shadow] placeholder:text-[#64748b] focus-visible:border-wz-accent focus-visible:shadow-[0_0_0_4px_var(--color-wz-soft)] focus-visible:outline-none aria-invalid:border-wz-err aria-invalid:focus-visible:border-wz-err aria-invalid:focus-visible:shadow-[0_0_0_4px_#fecaca] motion-reduce:transition-none text-[.95rem]" id="la-name" name="fullname" required autocomplete="name" placeholder="Dana Whitfield" aria-describedby="la-name-error" data-msg-required="Enter your full name.">
        <p class="flex items-start gap-[7px] text-[.84rem] font-medium text-wz-err empty:hidden before:mt-px before:grid before:size-[17px] before:flex-none before:place-items-center before:rounded-full before:bg-wz-err before:text-[.72rem] before:leading-none before:font-extrabold before:text-white before:wz-bang" id="la-name-error"></p></div>
      <div class="grid min-w-0 content-start gap-1.5"><label class="text-[.88rem] font-semibold" for="la-user">Username</label>
        <input class="min-h-11 w-full rounded-[10px] border-[1.5px] border-wz-field bg-white px-3 py-2.5 transition-[border-color,box-shadow] placeholder:text-[#64748b] focus-visible:border-wz-accent focus-visible:shadow-[0_0_0_4px_var(--color-wz-soft)] focus-visible:outline-none aria-invalid:border-wz-err aria-invalid:focus-visible:border-wz-err aria-invalid:focus-visible:shadow-[0_0_0_4px_#fecaca] motion-reduce:transition-none text-[.95rem]" id="la-user" name="username" required pattern="^[a-z0-9_]{3,20}$" autocomplete="username" autocapitalize="none" spellcheck="false" placeholder="dana_w" aria-describedby="la-user-hint la-user-error"
          data-msg-required="Choose a username." data-msg-pattern="Use 3 to 20 lowercase letters, numbers or underscores.">
        <p class="text-[.8rem] text-wz-muted" id="la-user-hint">3–20 lowercase letters, numbers or _</p>
        <p class="flex items-start gap-[7px] text-[.84rem] font-medium text-wz-err empty:hidden before:mt-px before:grid before:size-[17px] before:flex-none before:place-items-center before:rounded-full before:bg-wz-err before:text-[.72rem] before:leading-none before:font-extrabold before:text-white before:wz-bang" id="la-user-error"></p></div>
    </div>
    <div class="grid min-w-0 content-start gap-1.5"><label class="text-[.88rem] font-semibold" for="la-country">Country</label>
      <select class="min-h-11 w-full rounded-[10px] border-[1.5px] border-wz-field bg-white px-3 py-2.5 transition-[border-color,box-shadow] placeholder:text-[#64748b] focus-visible:border-wz-accent focus-visible:shadow-[0_0_0_4px_var(--color-wz-soft)] focus-visible:outline-none aria-invalid:border-wz-err aria-invalid:focus-visible:border-wz-err aria-invalid:focus-visible:shadow-[0_0_0_4px_#fecaca] motion-reduce:transition-none text-[.95rem]" id="la-country" name="country" required autocomplete="country-name" aria-describedby="la-country-error" data-msg-required="Choose the country your business is based in.">
        <option value="">Choose a country</option><option>Australia</option><option>Canada</option><option>Germany</option><option>Ireland</option><option>New Zealand</option><option>United Kingdom</option><option>United States</option>
      </select>
      <p class="flex items-start gap-[7px] text-[.84rem] font-medium text-wz-err empty:hidden before:mt-px before:grid before:size-[17px] before:flex-none before:place-items-center before:rounded-full before:bg-wz-err before:text-[.72rem] before:leading-none before:font-extrabold before:text-white before:wz-bang" id="la-country-error"></p></div>
  </section>

  <section class="mt-[26px] grid animate-wz-in gap-[18px] motion-reduce:animate-none" data-step aria-labelledby="la-h3">
    <h3 class="text-[1.15rem] font-bold tracking-[-.01em] focus:outline-none" id="la-h3" tabindex="-1"><span class="mb-0.5 block text-[.72rem] font-bold tracking-[.1em] text-wz-dark uppercase">Step 3 of 3</span>Check and confirm</h3>
    <dl class="grid divide-y divide-[#e2e8f0] overflow-hidden rounded-xl border border-[#e2e8f0] text-[.92rem]">
      <div class="flex justify-between gap-4 px-3.5 py-2.5"><dt class="text-wz-muted">Email</dt><dd class="text-right font-semibold wrap-anywhere" data-out="email">None</dd></div>
      <div class="flex justify-between gap-4 px-3.5 py-2.5"><dt class="text-wz-muted">Name</dt><dd class="text-right font-semibold wrap-anywhere" data-out="fullname">None</dd></div>
      <div class="flex justify-between gap-4 px-3.5 py-2.5"><dt class="text-wz-muted">Username</dt><dd class="text-right font-semibold wrap-anywhere" data-out="username">None</dd></div>
      <div class="flex justify-between gap-4 px-3.5 py-2.5"><dt class="text-wz-muted">Country</dt><dd class="text-right font-semibold wrap-anywhere" data-out="country">None</dd></div>
    </dl>
    <div class="flex items-start gap-2.5 text-[.92rem]"><span class="relative mt-px size-5 flex-none"><input class="peer block size-5 cursor-pointer appearance-none rounded-md border-[1.5px] border-wz-field bg-white transition-colors checked:border-wz-accent checked:bg-wz-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-wz-accent aria-invalid:border-wz-err aria-invalid:bg-wz-err-soft motion-reduce:transition-none" type="checkbox" id="la-terms" name="terms" required aria-describedby="la-terms-error" data-msg-required="Accept the terms to create your account."><svg class="pointer-events-none absolute top-[3px] left-[3px] hidden size-3.5 fill-none stroke-white stroke-[3.5] peer-checked:block" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
      <div class="grid gap-1"><label class="cursor-pointer" for="la-terms">I agree to the Ledgerly Terms of Service and Privacy Policy</label><p class="flex items-start gap-[7px] text-[.84rem] font-medium text-wz-err empty:hidden before:mt-px before:grid before:size-[17px] before:flex-none before:place-items-center before:rounded-full before:bg-wz-err before:text-[.72rem] before:leading-none before:font-extrabold before:text-white before:wz-bang" id="la-terms-error"></p></div></div>
    <div class="flex items-start gap-2.5 text-[.92rem]"><span class="relative mt-px size-5 flex-none"><input class="peer block size-5 cursor-pointer appearance-none rounded-md border-[1.5px] border-wz-field bg-white transition-colors checked:border-wz-accent checked:bg-wz-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-wz-accent aria-invalid:border-wz-err aria-invalid:bg-wz-err-soft motion-reduce:transition-none" type="checkbox" id="la-news" name="updates"><svg class="pointer-events-none absolute top-[3px] left-[3px] hidden size-3.5 fill-none stroke-white stroke-[3.5] peer-checked:block" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
      <div class="grid gap-1"><label class="cursor-pointer" for="la-news">Email me product updates, about twice a month (optional)</label></div></div>
  </section>

  <section class="mt-[26px] grid animate-wz-in justify-items-center gap-[18px] pt-3 pb-1 text-center motion-reduce:animate-none" data-done hidden aria-labelledby="la-h4">
    <span class="grid size-14 place-items-center rounded-full bg-[#ecfdf5] text-wz-ok"><svg class="size-[26px] fill-none stroke-current stroke-[2.5]" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5.5" width="17" height="13" rx="2"/><path d="m4 7 8 6 8-6"/></svg></span>
    <h3 class="text-[1.15rem] font-bold tracking-[-.01em] focus:outline-none" id="la-h4" tabindex="-1">Check your inbox</h3>
    <p class="text-[.92rem] text-wz-muted">We sent a confirmation link to <b class="text-wz-ink wrap-anywhere" data-out="email">your email</b>. Open it within 24 hours to activate your account.</p>
    <button type="button" class="inline-flex min-h-11 cursor-pointer items-center justify-center rounded-[10px] border-[1.5px] border-wz-field bg-white px-[22px] text-[.95rem] font-semibold text-wz-dark transition-colors hover:bg-[#eff6ff] focus-visible:shadow-[0_0_0_3px_#fff,0_0_0_5px_var(--color-wz-accent)] focus-visible:outline-none motion-reduce:transition-none" data-restart>Start over</button>
  </section>

  <div class="mt-7 flex gap-2.5 border-t border-[#e2e8f0] pt-5">
    <button type="button" class="inline-flex min-h-11 cursor-pointer items-center justify-center rounded-[10px] border-[1.5px] border-wz-field bg-white px-[22px] text-[.95rem] font-semibold text-wz-dark transition-colors hover:bg-[#eff6ff] focus-visible:shadow-[0_0_0_3px_#fff,0_0_0_5px_var(--color-wz-accent)] focus-visible:outline-none motion-reduce:transition-none" data-back hidden>Back</button>
    <button type="button" class="ms-auto inline-flex min-h-11 cursor-pointer items-center justify-center rounded-[10px] border-[1.5px] border-wz-accent bg-wz-accent px-[22px] text-[.95rem] font-semibold text-white transition-colors hover:bg-wz-dark focus-visible:shadow-[0_0_0_3px_#fff,0_0_0_5px_var(--color-wz-accent)] focus-visible:outline-none motion-reduce:transition-none" data-next hidden>Continue</button>
    <button type="submit" class="ms-auto inline-flex min-h-11 cursor-pointer items-center justify-center rounded-[10px] border-[1.5px] border-wz-accent bg-wz-accent px-[22px] text-[.95rem] font-semibold text-white transition-colors hover:bg-wz-dark focus-visible:shadow-[0_0_0_3px_#fff,0_0_0_5px_var(--color-wz-accent)] focus-visible:outline-none motion-reduce:transition-none">Create account</button>
  </div>
</form></div>

<div class="mx-auto w-full max-w-[420px]"><span class="mb-3 block text-[.72rem] tracking-[.12em] text-wz-muted uppercase">Passwordless sign-in</span>
<form class="rounded-[18px] bg-white p-7 text-wz-ink shadow-[0_1px_2px_rgba(15,23,42,.06),0_12px_32px_-14px_rgba(30,58,138,.22)] max-[600px]:px-[18px] max-[600px]:py-6" data-wizard aria-labelledby="pl-title">
  <div class="mb-[18px] flex items-center gap-2.5 font-bold tracking-[-.01em]"><span class="grid size-[30px] place-items-center rounded-[9px] bg-wz-accent"><svg class="size-4 fill-none stroke-white stroke-3" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14h9"/></svg></span>Ledgerly</div>
  <h2 class="text-[1.3rem] font-bold tracking-[-.015em]" id="pl-title">Sign in to Ledgerly</h2>
  <p class="mt-1 text-[.92rem] text-wz-muted">No password needed. We email you a one-time code.</p>
  <ol class="mt-6 grid auto-cols-fr grid-flow-col gap-2" data-stepper aria-label="Progress">
    <li class="group flex flex-wrap items-center gap-1.5 text-[.8rem] text-wz-muted before:mb-1 before:h-1 before:basis-full before:rounded before:bg-wz-line before:transition-colors data-[state=current]:font-semibold data-[state=current]:text-wz-ink data-[state=current]:before:bg-[linear-gradient(90deg,var(--color-wz-accent)_50%,var(--color-wz-line)_50%)] data-[state=done]:font-semibold data-[state=done]:text-wz-dark data-[state=done]:before:bg-wz-accent motion-reduce:before:transition-none"><svg class="hidden size-3.5 fill-none stroke-current stroke-3 group-data-[state=done]:block" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Email<span class="sr-only" data-sr></span></li>
    <li class="group flex flex-wrap items-center gap-1.5 text-[.8rem] text-wz-muted before:mb-1 before:h-1 before:basis-full before:rounded before:bg-wz-line before:transition-colors data-[state=current]:font-semibold data-[state=current]:text-wz-ink data-[state=current]:before:bg-[linear-gradient(90deg,var(--color-wz-accent)_50%,var(--color-wz-line)_50%)] data-[state=done]:font-semibold data-[state=done]:text-wz-dark data-[state=done]:before:bg-wz-accent motion-reduce:before:transition-none"><svg class="hidden size-3.5 fill-none stroke-current stroke-3 group-data-[state=done]:block" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Code<span class="sr-only" data-sr></span></li>
  </ol>

  <section class="mt-[26px] grid animate-wz-in gap-[18px] motion-reduce:animate-none" data-step aria-labelledby="pl-h1">
    <h3 class="text-[1.15rem] font-bold tracking-[-.01em] focus:outline-none" id="pl-h1" tabindex="-1"><span class="mb-0.5 block text-[.72rem] font-bold tracking-[.1em] text-wz-dark uppercase">Step 1 of 2</span>What's your email?</h3>
    <div class="grid min-w-0 content-start gap-1.5"><label class="text-[.88rem] font-semibold" for="pl-email">Work email</label>
      <input class="min-h-11 w-full rounded-[10px] border-[1.5px] border-wz-field bg-white px-3 py-2.5 transition-[border-color,box-shadow] placeholder:text-[#64748b] focus-visible:border-wz-accent focus-visible:shadow-[0_0_0_4px_var(--color-wz-soft)] focus-visible:outline-none aria-invalid:border-wz-err aria-invalid:focus-visible:border-wz-err aria-invalid:focus-visible:shadow-[0_0_0_4px_#fecaca] motion-reduce:transition-none text-[.95rem]" id="pl-email" name="email" type="email" required autocomplete="email" placeholder="[email protected]" aria-describedby="pl-email-error"
        data-msg-required="Enter the email you use for Ledgerly." data-msg-type="Enter an email address like [email protected].">
      <p class="flex items-start gap-[7px] text-[.84rem] font-medium text-wz-err empty:hidden before:mt-px before:grid before:size-[17px] before:flex-none before:place-items-center before:rounded-full before:bg-wz-err before:text-[.72rem] before:leading-none before:font-extrabold before:text-white before:wz-bang" id="pl-email-error"></p></div>
  </section>

  <section class="mt-[26px] grid animate-wz-in gap-[18px] motion-reduce:animate-none" data-step aria-labelledby="pl-h2">
    <h3 class="text-[1.15rem] font-bold tracking-[-.01em] focus:outline-none" id="pl-h2" tabindex="-1"><span class="mb-0.5 block text-[.72rem] font-bold tracking-[.1em] text-wz-dark uppercase">Step 2 of 2</span>Enter your code</h3>
    <p class="-mt-2.5 text-[.92rem] text-wz-muted">We sent a 6-digit code to <b class="text-wz-ink wrap-anywhere" data-out="email">your email</b>.</p>
    <div class="grid min-w-0 content-start gap-1.5"><label class="text-[.88rem] font-semibold" for="pl-code">6-digit code</label>
      <input class="min-h-11 w-full rounded-[10px] border-[1.5px] border-wz-field bg-white px-3 py-2.5 transition-[border-color,box-shadow] placeholder:text-[#64748b] focus-visible:border-wz-accent focus-visible:shadow-[0_0_0_4px_var(--color-wz-soft)] focus-visible:outline-none aria-invalid:border-wz-err aria-invalid:focus-visible:border-wz-err aria-invalid:focus-visible:shadow-[0_0_0_4px_#fecaca] motion-reduce:transition-none min-h-[58px] text-center indent-[.5em] font-mono text-[1.6rem] font-bold tracking-[.5em]" id="pl-code" name="code" required inputmode="numeric" autocomplete="one-time-code" pattern="\d{6}" maxlength="6" aria-describedby="pl-code-hint pl-code-error"
        data-msg-required="Enter the 6-digit code from the email." data-msg-pattern="The code is 6 digits, numbers only.">
      <p class="text-[.8rem] text-wz-muted" id="pl-code-hint">The code expires in 10 minutes.</p>
      <p class="flex items-start gap-[7px] text-[.84rem] font-medium text-wz-err empty:hidden before:mt-px before:grid before:size-[17px] before:flex-none before:place-items-center before:rounded-full before:bg-wz-err before:text-[.72rem] before:leading-none before:font-extrabold before:text-white before:wz-bang" id="pl-code-error"></p></div>
    <p class="-mt-1 text-[.88rem] text-wz-muted">Didn't get it? <button type="button" class="cursor-pointer rounded font-semibold text-wz-accent underline underline-offset-3 hover:text-wz-dark focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-wz-accent aria-disabled:cursor-default aria-disabled:text-wz-muted aria-disabled:no-underline p-0.5" data-resend>Resend code</button><span class="block font-semibold text-wz-ok" data-resend-msg aria-live="polite"></span></p>
  </section>

  <section class="mt-[26px] grid animate-wz-in justify-items-center gap-[18px] pt-3 pb-1 text-center motion-reduce:animate-none" data-done hidden aria-labelledby="pl-h3">
    <span class="grid size-14 place-items-center rounded-full bg-[#ecfdf5] text-wz-ok"><svg class="size-[26px] fill-none stroke-current stroke-[2.5]" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>
    <h3 class="text-[1.15rem] font-bold tracking-[-.01em] focus:outline-none" id="pl-h3" tabindex="-1">You're signed in</h3>
    <p class="text-[.92rem] text-wz-muted">Welcome back, <b class="text-wz-ink wrap-anywhere" data-out="email">you</b>. Your books are ready where you left them.</p>
    <button type="button" class="inline-flex min-h-11 cursor-pointer items-center justify-center rounded-[10px] border-[1.5px] border-wz-field bg-white px-[22px] text-[.95rem] font-semibold text-wz-dark transition-colors hover:bg-[#eff6ff] focus-visible:shadow-[0_0_0_3px_#fff,0_0_0_5px_var(--color-wz-accent)] focus-visible:outline-none motion-reduce:transition-none" data-restart>Sign out</button>
  </section>

  <div class="mt-[22px] flex flex-col gap-2.5">
    <button type="button" class="w-full inline-flex min-h-11 cursor-pointer items-center justify-center rounded-[10px] border-[1.5px] border-wz-accent bg-wz-accent px-[22px] text-[.95rem] font-semibold text-white transition-colors hover:bg-wz-dark focus-visible:shadow-[0_0_0_3px_#fff,0_0_0_5px_var(--color-wz-accent)] focus-visible:outline-none motion-reduce:transition-none" data-next hidden>Email me a code</button>
    <button type="submit" class="w-full inline-flex min-h-11 cursor-pointer items-center justify-center rounded-[10px] border-[1.5px] border-wz-accent bg-wz-accent px-[22px] text-[.95rem] font-semibold text-white transition-colors hover:bg-wz-dark focus-visible:shadow-[0_0_0_3px_#fff,0_0_0_5px_var(--color-wz-accent)] focus-visible:outline-none motion-reduce:transition-none">Sign in</button>
    <button type="button" class="self-center px-1 py-1.5 text-[.95rem] cursor-pointer rounded font-semibold text-wz-accent underline underline-offset-3 hover:text-wz-dark focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-wz-accent aria-disabled:cursor-default aria-disabled:text-wz-muted aria-disabled:no-underline" data-back hidden>Use a different email</button>
  </div>
</form></div>
CSS
/* error badge: "!" for sighted users, empty alt text so screen readers skip it (older browsers get the plain "!") */
@utility wz-bang { content: "!"; content: "!" / ""; }
@theme {
  --color-wz-accent: #1d4ed8;
  --color-wz-dark: #1e3a8a;
  --color-wz-soft: #dbeafe;
  --color-wz-err: #b91c1c;
  --color-wz-err-soft: #fef2f2;
  --color-wz-ok: #047857;
  --color-wz-ink: #0f172a;
  --color-wz-muted: #475569;
  --color-wz-line: #cbd5e1;
  --color-wz-field: #64748b;
  --animate-wz-in: wz-in .3s ease;
  @keyframes wz-in { from { opacity: 0; transform: translateY(6px); } }
}
JavaScript
// Signup wizard: one step at a time, stepper marked with data-state and aria-current="step", focus on the new
// step's heading. Validation uses the native constraints (required, type, pattern, minlength, setCustomValidity)
// but shows its own messages: each field's #<id>-error gets a specific text, aria-invalid is set and focus
// goes to the first invalid field. Flagged fields re-check as the user types, so the message clears when fixed.
document.querySelectorAll('[data-wizard]').forEach(function (wz) {
  var panels = wz.querySelectorAll('[data-step]'), steps = wz.querySelectorAll('[data-stepper] > li');
  var back = wz.querySelector('[data-back]'), next = wz.querySelector('[data-next]');
  var submit = wz.querySelector('[type="submit"]'), done = wz.querySelector('[data-done]');
  var summary = wz.querySelector('[data-summary]'), resend = wz.querySelector('[data-resend]'), at = 0;
  function mark(i) {
    steps.forEach(function (s, n) {
      s.dataset.state = n < i ? 'done' : n === i ? 'current' : 'next';
      if (n === i) s.setAttribute('aria-current', 'step'); else s.removeAttribute('aria-current');
      s.querySelector('[data-sr]').textContent = n < i ? ', completed' : '';
    });
  }
  function go(i, focus) {
    at = i; mark(i); say('');
    panels.forEach(function (p, n) { p.hidden = n !== i; });
    back.hidden = i === 0; next.hidden = i === panels.length - 1; submit.hidden = !next.hidden;
    wz.querySelectorAll('[data-out]').forEach(function (o) {
      var f = wz.elements[o.dataset.out];
      o.textContent = (f && f.value) || 'None';
    });
    if (focus) panels[i].querySelector('[tabindex="-1"]').focus();
  }
  // The summary is a polite live region; only write when the text changes so it is not re-announced.
  function say(t) { if (summary && summary.textContent !== t) summary.textContent = t; }
  function tally() {
    var n = panels[at].querySelectorAll('[aria-invalid]').length;
    say(n ? n + (n > 1 ? ' fields need' : ' field needs') + ' attention' : '');
  }
  // One field: pick the data-msg-* text for the failed constraint, else fall back to the browser's message.
  function check(f) {
    var v = f.validity, out = document.getElementById(f.id + '-error');
    var key = v.valueMissing ? 'msgRequired' : v.typeMismatch ? 'msgType' : v.tooShort ? 'msgShort' : v.patternMismatch ? 'msgPattern' : '';
    if (v.valid) f.removeAttribute('aria-invalid'); else f.setAttribute('aria-invalid', 'true');
    if (out) out.textContent = v.valid ? '' : f.dataset[key] || f.validationMessage;
    return v.valid;
  }
  function valid() {
    var bad = [].filter.call(panels[at].querySelectorAll('input, select, textarea'), function (f) { return !check(f); });
    tally();
    if (bad[0]) bad[0].focus();
    return !bad.length;
  }
  // Live rules: [data-match="<id>"] must equal that field (custom validity); every <li data-rule="regex">
  // inside [data-rules="<id>"] gets data-met="true|false" and a hidden ", done" for screen readers.
  function live() {
    wz.querySelectorAll('[data-match]').forEach(function (c) {
      var o = document.getElementById(c.dataset.match);
      c.setCustomValidity(o && c.value && c.value !== o.value ? c.dataset.msgMatch || 'The values do not match.' : '');
    });
    wz.querySelectorAll('[data-rules]').forEach(function (list) {
      var f = document.getElementById(list.dataset.rules);
      list.querySelectorAll('[data-rule]').forEach(function (r) {
        var ok = !!f && new RegExp(r.dataset.rule).test(f.value);
        r.dataset.met = ok; r.querySelector('[data-sr]').textContent = ok ? ', done' : '';
      });
    });
  }
  wz.noValidate = true;
  wz.addEventListener('input', function () {
    live();
    panels[at].querySelectorAll('[aria-invalid]').forEach(check);
    tally();
  });
  back.addEventListener('click', function () { go(at - 1, true); });
  next.addEventListener('click', function () { if (valid()) go(at + 1, true); });
  wz.addEventListener('submit', function (e) {
    e.preventDefault();
    if (!valid()) return;
    if (at < panels.length - 1) return go(at + 1, true);
    // Last step: send the form here (fetch or wz.submit()). The demo shows the done panel instead.
    panels[at].hidden = true; back.parentNode.hidden = true; mark(steps.length);
    done.hidden = false; done.querySelector('[tabindex="-1"]').focus();
  });
  if (done) done.querySelector('[data-restart]').addEventListener('click', function () {
    wz.reset(); live(); done.hidden = true; back.parentNode.hidden = false; go(0, true);
  });
  // Resend: a polite "New code sent", then a 30 s cool-down in the button text. aria-disabled (not disabled)
  // keeps keyboard focus on the button while it counts down.
  if (resend) resend.addEventListener('click', function () {
    if (resend.getAttribute('aria-disabled')) return;
    var label = resend.textContent, left = 30, note = wz.querySelector('[data-resend-msg]');
    if (note) { note.textContent = ''; setTimeout(function () { note.textContent = 'New code sent.'; }, 60); }
    resend.setAttribute('aria-disabled', 'true');
    (function tick() {
      if (left) { resend.textContent = 'Resend in ' + left-- + ' s'; setTimeout(tick, 1000); }
      else { resend.textContent = label; resend.removeAttribute('aria-disabled'); }
    })();
  });
  live(); go(0);
});

About this set

Two sign-up flows for a fictional accounting app, built around per-step validation. Create an account, inline errors is a three-step signup (account, profile, confirm) with a work email, a password with a live requirements checklist that ticks as you type, a confirm-password field, a username with a pattern, a country select and a required terms checkbox. Passwordless sign-in is a compact two-step card: an email, then a six-digit one-time code with large spaced digits, a Resend code button with a 30 second cool-down and a Use a different email link. Use the first for new accounts, the second for magic-code logins. Validation relies on the browser’s own constraints (required, type, pattern, minlength and setCustomValidity for the password match) but replaces the browser bubbles with its own messages: on Continue every field in the step is checked, each failing field gets aria-invalid, a red border and a specific message with an icon, the signup form shows how many fields need attention, and focus moves to the first problem. Flagged fields re-check as you type, so messages clear as soon as they are fixed. The messages live in data attributes, so changing the wording needs no script edits. Without JavaScript every step shows and the browser validates on submit.

What’s included

  • Native constraint validation with custom inline messages from data-msg-* attributes
  • Focus moves to the first invalid field; a live summary counts the fields that need attention
  • Live password checklist and a confirm-password match via setCustomValidity
  • One-time code field with numeric keyboard, autofill hint and a resend cool-down
  • One shared vanilla script, identical in the CSS, Bootstrap and Tailwind versions

Accessibility

Each field's error paragraph and hint are linked through aria-describedby, invalid fields get aria-invalid="true", and errors combine a red border with an icon and text so colour is never the only signal. The signup error count sits in a polite live region, the resend confirmation is announced politely, and the password checklist adds a hidden ", done" to each met rule. The stepper marks the current step with aria-current="step", focus moves to the new step heading or the first invalid field, every control has a visible focus style, and animations are switched off under prefers-reduced-motion.

Customise it

Change the --wz-accent, --wz-err and --wz-ok custom properties (plain CSS and Bootstrap, which also sets --bs-border-color on the card) or the wz-accent, wz-err and wz-ok colours in the Tailwind @theme block. Edit the data-msg-required, data-msg-type, data-msg-short, data-msg-pattern and data-msg-match attributes to reword the messages, and the data-rule regular expressions to change the password checklist.