Open demo

CSS code

HTML
<div class="ot">
  <main class="ot-box" data-otp>
    <div class="ot-mark" aria-hidden="true">N</div>
    <h1>Check your email</h1>
    <p class="ot-sub">We sent a 6-digit code to <strong>[email protected]</strong>. It expires in 10 minutes.</p>
    <form action="#" method="post" novalidate>
      <fieldset class="ot-set">
        <legend>Verification code</legend>
        <div class="ot-codes">
        <input type="text" inputmode="numeric" placeholder=" " autocomplete="one-time-code" aria-label="Digit 1 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 2 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 3 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 4 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 5 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 6 of 6" aria-describedby="ot-msg" data-otp-digit>
        </div>
      </fieldset>
      <p class="ot-msg" id="ot-msg" aria-live="polite" data-otp-msg>Paste the whole code and it will fill every box.</p>
      <button class="ot-btn" type="submit">Verify</button>
    </form>
    <p class="ot-resend">Didn’t get it? <button type="button" data-resend disabled>Resend in 30s</button></p>
    <p class="ot-foot"><a href="#">Use a different email</a></p>
  </main>
</div>
CSS
/* One-time code screen: six single-digit boxes in a fieldset, one message line that doubles as the error, and a resend countdown. */
.ot{--ot-brand:#4f46e5;--ot-brand-dark:#4338ca;--ot-ink:#111827;--ot-muted:#6b7280;--ot-line:#8b93a1;--ot-bad:#b91c1c;
  min-height:100vh;display:grid;grid-template-columns:minmax(0,1fr);place-items:center;padding:28px 20px;background:#f5f6fa;color:var(--ot-ink);
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
.ot h1,.ot p{margin:0}
.ot-box{width:100%;max-width:420px;background:#fff;border:1px solid #e5e7eb;border-radius:16px;padding:38px 34px;box-shadow:0 10px 34px rgba(17,24,39,.06);text-align:center}
.ot-mark{width:42px;height:42px;border-radius:12px;background:var(--ot-brand);display:grid;place-items:center;color:#fff;font-weight:800;margin:0 auto 22px}
.ot h1{font-size:1.4rem;letter-spacing:-.02em;margin-bottom:7px}
.ot-sub{color:var(--ot-muted);font-size:.93rem;line-height:1.6;margin-bottom:22px!important}
.ot-set{border:0;margin:0;padding:0;min-width:0}
.ot-set legend{padding:0;margin:0 auto 10px;font-size:.85rem;font-weight:600}
.ot-codes{display:flex;gap:9px;justify-content:center;margin-bottom:8px}
.ot-codes input{flex:1 1 0;min-width:0;max-width:52px;height:60px;text-align:center;font:inherit;font-size:1.5rem;font-weight:700;color:inherit;
  border:1px solid var(--ot-line);border-radius:11px;background:#fff;transition:border-color .15s,box-shadow .15s}
.ot-codes input:focus{outline:none;border-color:var(--ot-brand);box-shadow:0 0 0 3px rgba(79,70,229,.25)}
.ot-codes input:not(:placeholder-shown){border-color:var(--ot-brand);background:#f5f4ff}
.ot-codes input[aria-invalid="true"]{border-color:var(--ot-bad)}
.ot-msg{font-size:.83rem;color:var(--ot-muted);min-height:1.3em;margin-bottom:22px!important}
.ot-msg[data-state="error"]{color:var(--ot-bad);font-weight:600}
.ot-btn{width:100%;padding:13px;border:0;border-radius:10px;background:var(--ot-brand);color:#fff;font:inherit;font-size:.97rem;font-weight:700;cursor:pointer;transition:background-color .15s}
.ot-btn:hover{background:var(--ot-brand-dark)}
.ot-resend{margin-top:20px!important;font-size:.88rem;color:var(--ot-muted)}
.ot-resend button{background:none;border:0;border-radius:3px;color:var(--ot-brand);font:inherit;font-weight:600;cursor:pointer;padding:0}
.ot-resend button:disabled{color:var(--ot-muted);cursor:default;font-weight:500}
.ot-foot{margin-top:24px!important;font-size:.89rem}
.ot a{color:var(--ot-brand);text-decoration:none;font-weight:600;border-radius:3px}
.ot a:hover{text-decoration:underline}
.ot a:focus-visible,.ot-btn:focus-visible,.ot-resend button:focus-visible{outline:2px solid var(--ot-brand);outline-offset:2px}
@media (max-width:400px){.ot-codes{gap:6px}.ot-codes input{height:52px;font-size:1.2rem}}
@media (prefers-reduced-motion:reduce){.ot-codes input,.ot-btn{transition:none}}
JavaScript
// One-time code: typing moves to the next box, Backspace on an empty box goes back, and pasting or
// autofilling the whole code spreads it across every box. Front end only: post the code in the submit handler.
document.querySelectorAll('[data-otp]').forEach(function (card) {
  var form = card.querySelector('form');
  var boxes = Array.prototype.slice.call(form.querySelectorAll('[data-otp-digit]'));
  var msg = card.querySelector('[data-otp-msg]');
  var resend = card.querySelector('[data-resend]');
  var helper = msg.textContent;

  function say(text, isError) {
    msg.textContent = text || helper;
    if (isError) msg.setAttribute('data-state', 'error'); else msg.removeAttribute('data-state');
    boxes.forEach(function (b) { if (isError && !b.value) b.setAttribute('aria-invalid', 'true'); else b.removeAttribute('aria-invalid'); });
  }
  function fill(from, digits) {
    for (var k = 0; k < digits.length && from + k < boxes.length; k++) boxes[from + k].value = digits[k];
    boxes[Math.min(from + digits.length, boxes.length - 1)].focus();
  }

  boxes.forEach(function (box, i) {
    box.addEventListener('input', function () {
      var digits = box.value.replace(/\D/g, '');
      box.value = digits.charAt(0);
      if (digits.length > 1) fill(i, digits);
      else if (digits && boxes[i + 1]) boxes[i + 1].focus();
      if (msg.hasAttribute('data-state')) say('', false);
    });
    box.addEventListener('keydown', function (e) {
      if (e.key === 'Backspace' && !box.value && boxes[i - 1]) { e.preventDefault(); boxes[i - 1].value = ''; boxes[i - 1].focus(); }
      else if (e.key === 'ArrowLeft' && boxes[i - 1]) { e.preventDefault(); boxes[i - 1].focus(); }
      else if (e.key === 'ArrowRight' && boxes[i + 1]) { e.preventDefault(); boxes[i + 1].focus(); }
    });
    box.addEventListener('paste', function (e) {
      var digits = ((e.clipboardData || window.clipboardData).getData('text') || '').replace(/\D/g, '');
      if (!digits) return;
      e.preventDefault();
      fill(digits.length >= boxes.length ? 0 : i, digits);
      say('', false);
    });
  });

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var empty = boxes.filter(function (b) { return !b.value; })[0];
    if (empty) { say('Enter all ' + boxes.length + ' digits of the code.', true); empty.focus(); return; }
    say('', false);
    // var code = boxes.map(function (b) { return b.value; }).join(''); → send it to your server here.
  });

  var timer;
  function countdown(seconds) {
    clearInterval(timer);
    resend.disabled = true;
    resend.textContent = 'Resend in ' + seconds + 's';
    timer = setInterval(function () {
      if (--seconds > 0) { resend.textContent = 'Resend in ' + seconds + 's'; return; }
      clearInterval(timer);
      resend.disabled = false;
      resend.textContent = 'Resend code';
    }, 1000);
  }
  resend.addEventListener('click', function () {
    boxes.forEach(function (b) { b.value = ''; });
    say('A new code is on its way. The previous one no longer works.', false);
    boxes[0].focus();
    countdown(30);
  });
  countdown(30);
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="ot">
  <main class="card w-100 text-center mx-auto" data-otp>
    <div class="card-body">
      <div class="ot-mark d-grid align-items-center justify-content-center text-white" aria-hidden="true">N</div>
      <h1>Check your email</h1>
      <p class="ot-sub text-body-secondary">We sent a 6-digit code to <strong class="text-body">[email protected]</strong>. It expires in 10 minutes.</p>
      <form action="#" method="post" novalidate>
        <fieldset>
          <legend>Verification code</legend>
          <div class="ot-codes d-flex justify-content-center">
        <input class="form-control" type="text" inputmode="numeric" placeholder=" " autocomplete="one-time-code" aria-label="Digit 1 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input class="form-control" type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 2 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input class="form-control" type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 3 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input class="form-control" type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 4 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input class="form-control" type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 5 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input class="form-control" type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 6 of 6" aria-describedby="ot-msg" data-otp-digit>
          </div>
        </fieldset>
        <p class="ot-msg text-body-secondary" id="ot-msg" aria-live="polite" data-otp-msg>Paste the whole code and it will fill every box.</p>
        <button class="btn btn-ot w-100" type="submit">Verify</button>
      </form>
      <p class="ot-resend text-body-secondary mb-0">Didn’t get it? <button class="btn btn-link" type="button" data-resend disabled>Resend in 30s</button></p>
      <p class="ot-foot mb-0"><a href="#">Use a different email</a></p>
    </div>
  </main>
</div>
CSS
/* Bootstrap .card with six .form-control digit boxes in a fieldset, a .btn and a .btn-link resend countdown. */
.ot{--ot-brand:#4f46e5;--ot-brand-dark:#4338ca;--ot-bad:#b91c1c;
  --bs-body-color:#111827;--bs-secondary-color:#6b7280;--bs-border-color:#8b93a1;
  --bs-body-font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
  min-height:100vh;display:grid;grid-template-columns:minmax(0,1fr);place-items:center;padding:28px 20px;background:#f5f6fa;color:var(--bs-body-color);font-family:var(--bs-body-font-family);line-height:normal}
.ot .card{--bs-card-border-color:#e5e7eb;--bs-card-border-radius:16px;--bs-card-spacer-y:38px;--bs-card-spacer-x:34px;max-width:420px;box-shadow:0 10px 34px rgba(17,24,39,.06)}
.ot-mark{width:42px;height:42px;border-radius:12px;background:var(--ot-brand);font-weight:800;margin:0 auto 22px}
.ot h1{font-size:1.4rem;letter-spacing:-.02em;font-weight:700;margin-bottom:7px}
.ot-sub{font-size:.93rem;line-height:1.6;margin-bottom:22px}
.ot legend{float:none;width:auto;font-size:.85rem;font-weight:600;margin:0 auto 10px}
.ot-codes{gap:9px;margin-bottom:8px}
.ot-codes .form-control{flex:1 1 0;min-width:0;max-width:52px;height:60px;padding:0;text-align:center;font-size:1.5rem;font-weight:700;border-radius:11px}
.ot-codes .form-control:focus{border-color:var(--ot-brand);box-shadow:0 0 0 3px rgba(79,70,229,.25)}
.ot-codes .form-control:not(:placeholder-shown){border-color:var(--ot-brand);background:#f5f4ff}
.ot-codes .form-control[aria-invalid="true"]{border-color:var(--ot-bad)}
.ot-msg{font-size:.83rem;min-height:1.3em;margin-bottom:22px}
.ot-msg[data-state="error"]{color:var(--ot-bad)!important;font-weight:600}
.ot .btn-ot{--bs-btn-bg:var(--ot-brand);--bs-btn-border-color:var(--ot-brand);--bs-btn-color:#fff;--bs-btn-hover-bg:var(--ot-brand-dark);--bs-btn-hover-border-color:var(--ot-brand-dark);
  --bs-btn-hover-color:#fff;--bs-btn-active-bg:var(--ot-brand-dark);--bs-btn-active-border-color:var(--ot-brand-dark);--bs-btn-active-color:#fff;
  --bs-btn-padding-y:13px;--bs-btn-font-size:.97rem;--bs-btn-font-weight:700;--bs-btn-border-radius:10px;--bs-btn-line-height:1.2;--bs-btn-focus-box-shadow:none}
.ot-resend{margin-top:20px;font-size:.88rem}
.ot .btn-link{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-font-size:inherit;--bs-btn-font-weight:600;--bs-btn-border-width:0;--bs-btn-border-radius:3px;
  --bs-btn-color:var(--ot-brand);--bs-btn-hover-color:var(--ot-brand);--bs-btn-active-color:var(--ot-brand);--bs-btn-disabled-color:var(--bs-secondary-color);
  --bs-btn-disabled-opacity:1;--bs-btn-focus-box-shadow:none;vertical-align:baseline;line-height:inherit;text-decoration:none}
.ot .btn-link:disabled{font-weight:500}
.ot-foot{margin-top:24px;font-size:.89rem}
.ot a{color:var(--ot-brand);text-decoration:none;font-weight:600;border-radius:3px}
.ot a:hover{text-decoration:underline}
.ot a:focus-visible,.ot .btn:focus-visible{outline:2px solid var(--ot-brand);outline-offset:2px}
@media (max-width:400px){.ot-codes{gap:6px}.ot-codes .form-control{height:52px;font-size:1.2rem}}
@media (prefers-reduced-motion:reduce){.ot .form-control,.ot .btn{transition:none}}
JavaScript
// One-time code: typing moves to the next box, Backspace on an empty box goes back, and pasting or
// autofilling the whole code spreads it across every box. Front end only: post the code in the submit handler.
document.querySelectorAll('[data-otp]').forEach(function (card) {
  var form = card.querySelector('form');
  var boxes = Array.prototype.slice.call(form.querySelectorAll('[data-otp-digit]'));
  var msg = card.querySelector('[data-otp-msg]');
  var resend = card.querySelector('[data-resend]');
  var helper = msg.textContent;

  function say(text, isError) {
    msg.textContent = text || helper;
    if (isError) msg.setAttribute('data-state', 'error'); else msg.removeAttribute('data-state');
    boxes.forEach(function (b) { if (isError && !b.value) b.setAttribute('aria-invalid', 'true'); else b.removeAttribute('aria-invalid'); });
  }
  function fill(from, digits) {
    for (var k = 0; k < digits.length && from + k < boxes.length; k++) boxes[from + k].value = digits[k];
    boxes[Math.min(from + digits.length, boxes.length - 1)].focus();
  }

  boxes.forEach(function (box, i) {
    box.addEventListener('input', function () {
      var digits = box.value.replace(/\D/g, '');
      box.value = digits.charAt(0);
      if (digits.length > 1) fill(i, digits);
      else if (digits && boxes[i + 1]) boxes[i + 1].focus();
      if (msg.hasAttribute('data-state')) say('', false);
    });
    box.addEventListener('keydown', function (e) {
      if (e.key === 'Backspace' && !box.value && boxes[i - 1]) { e.preventDefault(); boxes[i - 1].value = ''; boxes[i - 1].focus(); }
      else if (e.key === 'ArrowLeft' && boxes[i - 1]) { e.preventDefault(); boxes[i - 1].focus(); }
      else if (e.key === 'ArrowRight' && boxes[i + 1]) { e.preventDefault(); boxes[i + 1].focus(); }
    });
    box.addEventListener('paste', function (e) {
      var digits = ((e.clipboardData || window.clipboardData).getData('text') || '').replace(/\D/g, '');
      if (!digits) return;
      e.preventDefault();
      fill(digits.length >= boxes.length ? 0 : i, digits);
      say('', false);
    });
  });

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var empty = boxes.filter(function (b) { return !b.value; })[0];
    if (empty) { say('Enter all ' + boxes.length + ' digits of the code.', true); empty.focus(); return; }
    say('', false);
    // var code = boxes.map(function (b) { return b.value; }).join(''); → send it to your server here.
  });

  var timer;
  function countdown(seconds) {
    clearInterval(timer);
    resend.disabled = true;
    resend.textContent = 'Resend in ' + seconds + 's';
    timer = setInterval(function () {
      if (--seconds > 0) { resend.textContent = 'Resend in ' + seconds + 's'; return; }
      clearInterval(timer);
      resend.disabled = false;
      resend.textContent = 'Resend code';
    }, 1000);
  }
  resend.addEventListener('click', function () {
    boxes.forEach(function (b) { b.value = ''; });
    say('A new code is on its way. The previous one no longer works.', false);
    boxes[0].focus();
    countdown(30);
  });
  countdown(30);
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="grid min-h-screen grid-cols-[minmax(0,1fr)] place-items-center bg-[#f5f6fa] px-5 py-7 font-[-apple-system,BlinkMacSystemFont,'Segoe_UI',Roboto,Helvetica,Arial,sans-serif] leading-[normal] text-ot-ink">
  <main class="w-full max-w-[420px] rounded-2xl border border-[#e5e7eb] bg-white px-[34px] py-[38px] text-center shadow-[0_10px_34px_rgba(17,24,39,.06)]" data-otp>
    <div class="mx-auto mb-[22px] grid size-[42px] place-items-center rounded-xl bg-ot-brand font-extrabold text-white" aria-hidden="true">N</div>
    <h1 class="mb-[7px] text-[1.4rem] font-bold tracking-[-.02em]">Check your email</h1>
    <p class="mb-[22px] text-[.93rem] leading-[1.6] text-ot-muted">We sent a 6-digit code to <strong class="text-ot-ink">[email protected]</strong>. It expires in 10 minutes.</p>
    <form action="#" method="post" novalidate>
      <fieldset class="min-w-0">
        <legend class="mx-auto mb-2.5 text-[.85rem] font-semibold">Verification code</legend>
        <div class="mb-2 flex justify-center gap-[9px] max-[400px]:gap-1.5">
        <input class="h-[60px] min-w-0 max-w-[52px] flex-1 rounded-[11px] border border-ot-line bg-white text-center text-2xl font-bold transition-[border-color,box-shadow] duration-150 focus:border-ot-brand focus:shadow-[0_0_0_3px_rgba(79,70,229,.25)] focus:outline-none aria-invalid:border-ot-bad max-[400px]:h-[52px] max-[400px]:text-[1.2rem] motion-reduce:transition-none [&:not(:placeholder-shown)]:border-ot-brand [&:not(:placeholder-shown)]:bg-[#f5f4ff]" type="text" inputmode="numeric" placeholder=" " autocomplete="one-time-code" aria-label="Digit 1 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input class="h-[60px] min-w-0 max-w-[52px] flex-1 rounded-[11px] border border-ot-line bg-white text-center text-2xl font-bold transition-[border-color,box-shadow] duration-150 focus:border-ot-brand focus:shadow-[0_0_0_3px_rgba(79,70,229,.25)] focus:outline-none aria-invalid:border-ot-bad max-[400px]:h-[52px] max-[400px]:text-[1.2rem] motion-reduce:transition-none [&:not(:placeholder-shown)]:border-ot-brand [&:not(:placeholder-shown)]:bg-[#f5f4ff]" type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 2 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input class="h-[60px] min-w-0 max-w-[52px] flex-1 rounded-[11px] border border-ot-line bg-white text-center text-2xl font-bold transition-[border-color,box-shadow] duration-150 focus:border-ot-brand focus:shadow-[0_0_0_3px_rgba(79,70,229,.25)] focus:outline-none aria-invalid:border-ot-bad max-[400px]:h-[52px] max-[400px]:text-[1.2rem] motion-reduce:transition-none [&:not(:placeholder-shown)]:border-ot-brand [&:not(:placeholder-shown)]:bg-[#f5f4ff]" type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 3 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input class="h-[60px] min-w-0 max-w-[52px] flex-1 rounded-[11px] border border-ot-line bg-white text-center text-2xl font-bold transition-[border-color,box-shadow] duration-150 focus:border-ot-brand focus:shadow-[0_0_0_3px_rgba(79,70,229,.25)] focus:outline-none aria-invalid:border-ot-bad max-[400px]:h-[52px] max-[400px]:text-[1.2rem] motion-reduce:transition-none [&:not(:placeholder-shown)]:border-ot-brand [&:not(:placeholder-shown)]:bg-[#f5f4ff]" type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 4 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input class="h-[60px] min-w-0 max-w-[52px] flex-1 rounded-[11px] border border-ot-line bg-white text-center text-2xl font-bold transition-[border-color,box-shadow] duration-150 focus:border-ot-brand focus:shadow-[0_0_0_3px_rgba(79,70,229,.25)] focus:outline-none aria-invalid:border-ot-bad max-[400px]:h-[52px] max-[400px]:text-[1.2rem] motion-reduce:transition-none [&:not(:placeholder-shown)]:border-ot-brand [&:not(:placeholder-shown)]:bg-[#f5f4ff]" type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 5 of 6" aria-describedby="ot-msg" data-otp-digit>
        <input class="h-[60px] min-w-0 max-w-[52px] flex-1 rounded-[11px] border border-ot-line bg-white text-center text-2xl font-bold transition-[border-color,box-shadow] duration-150 focus:border-ot-brand focus:shadow-[0_0_0_3px_rgba(79,70,229,.25)] focus:outline-none aria-invalid:border-ot-bad max-[400px]:h-[52px] max-[400px]:text-[1.2rem] motion-reduce:transition-none [&:not(:placeholder-shown)]:border-ot-brand [&:not(:placeholder-shown)]:bg-[#f5f4ff]" type="text" inputmode="numeric" placeholder=" " autocomplete="off" aria-label="Digit 6 of 6" aria-describedby="ot-msg" data-otp-digit>
        </div>
      </fieldset>
      <p class="mb-[22px] min-h-[1.3em] text-[.83rem] text-ot-muted data-[state=error]:font-semibold data-[state=error]:text-ot-bad" id="ot-msg" aria-live="polite" data-otp-msg>Paste the whole code and it will fill every box.</p>
      <button class="w-full cursor-pointer rounded-[10px] bg-ot-brand p-[13px] text-[.97rem] font-bold text-white transition-colors duration-150 hover:bg-ot-brand-dark focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ot-brand motion-reduce:transition-none" type="submit">Verify</button>
    </form>
    <p class="mt-5 text-[.88rem] text-ot-muted">Didn’t get it? <button class="cursor-pointer rounded-[3px] font-semibold text-ot-brand focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ot-brand disabled:cursor-default disabled:font-medium disabled:text-ot-muted" type="button" data-resend disabled>Resend in 30s</button></p>
    <p class="mt-6 text-[.89rem]"><a class="rounded-[3px] font-semibold text-ot-brand hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ot-brand" href="#">Use a different email</a></p>
  </main>
</div>
CSS
@theme {
  --color-ot-brand: #4f46e5;
  --color-ot-brand-dark: #4338ca;
  --color-ot-ink: #111827;
  --color-ot-muted: #6b7280;
  --color-ot-line: #8b93a1;
  --color-ot-bad: #b91c1c;
}
JavaScript
// One-time code: typing moves to the next box, Backspace on an empty box goes back, and pasting or
// autofilling the whole code spreads it across every box. Front end only: post the code in the submit handler.
document.querySelectorAll('[data-otp]').forEach(function (card) {
  var form = card.querySelector('form');
  var boxes = Array.prototype.slice.call(form.querySelectorAll('[data-otp-digit]'));
  var msg = card.querySelector('[data-otp-msg]');
  var resend = card.querySelector('[data-resend]');
  var helper = msg.textContent;

  function say(text, isError) {
    msg.textContent = text || helper;
    if (isError) msg.setAttribute('data-state', 'error'); else msg.removeAttribute('data-state');
    boxes.forEach(function (b) { if (isError && !b.value) b.setAttribute('aria-invalid', 'true'); else b.removeAttribute('aria-invalid'); });
  }
  function fill(from, digits) {
    for (var k = 0; k < digits.length && from + k < boxes.length; k++) boxes[from + k].value = digits[k];
    boxes[Math.min(from + digits.length, boxes.length - 1)].focus();
  }

  boxes.forEach(function (box, i) {
    box.addEventListener('input', function () {
      var digits = box.value.replace(/\D/g, '');
      box.value = digits.charAt(0);
      if (digits.length > 1) fill(i, digits);
      else if (digits && boxes[i + 1]) boxes[i + 1].focus();
      if (msg.hasAttribute('data-state')) say('', false);
    });
    box.addEventListener('keydown', function (e) {
      if (e.key === 'Backspace' && !box.value && boxes[i - 1]) { e.preventDefault(); boxes[i - 1].value = ''; boxes[i - 1].focus(); }
      else if (e.key === 'ArrowLeft' && boxes[i - 1]) { e.preventDefault(); boxes[i - 1].focus(); }
      else if (e.key === 'ArrowRight' && boxes[i + 1]) { e.preventDefault(); boxes[i + 1].focus(); }
    });
    box.addEventListener('paste', function (e) {
      var digits = ((e.clipboardData || window.clipboardData).getData('text') || '').replace(/\D/g, '');
      if (!digits) return;
      e.preventDefault();
      fill(digits.length >= boxes.length ? 0 : i, digits);
      say('', false);
    });
  });

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var empty = boxes.filter(function (b) { return !b.value; })[0];
    if (empty) { say('Enter all ' + boxes.length + ' digits of the code.', true); empty.focus(); return; }
    say('', false);
    // var code = boxes.map(function (b) { return b.value; }).join(''); → send it to your server here.
  });

  var timer;
  function countdown(seconds) {
    clearInterval(timer);
    resend.disabled = true;
    resend.textContent = 'Resend in ' + seconds + 's';
    timer = setInterval(function () {
      if (--seconds > 0) { resend.textContent = 'Resend in ' + seconds + 's'; return; }
      clearInterval(timer);
      resend.disabled = false;
      resend.textContent = 'Resend code';
    }, 1000);
  }
  resend.addEventListener('click', function () {
    boxes.forEach(function (b) { b.value = ''; });
    say('A new code is on its way. The previous one no longer works.', false);
    boxes[0].focus();
    countdown(30);
  });
  countdown(30);
});

About this set

A verification screen for a 6-digit code sent by email or SMS. Six single-digit boxes sit in a fieldset labelled Verification code, followed by one message line, a Verify button, a Resend link with a 30 second countdown and a link to use a different email. A small script makes the boxes behave like one field: typing moves to the next box, Backspace on an empty box goes back, the arrow keys move between boxes, and pasting the code into any box spreads it across all six. The first box has autocomplete=”one-time-code” and no length limit, so a code offered by the phone keyboard or browser is spread across the boxes too. Pressing Verify with empty boxes turns the message line into an error, marks the empty boxes aria-invalid and moves focus to the first gap; the message line is a polite live region linked to every box with aria-describedby. Resend clears the boxes and restarts the countdown. The boxes shrink to fit narrow phones. Nothing is verified: the script marks where to send the code to your server.

What’s included

  • Auto-advance, Backspace to previous box and arrow key navigation
  • Paste or one-time-code autofill spreads the code over all boxes
  • Error message linked with aria-describedby and aria-invalid
  • Resend button with a 30 second countdown that restarts
  • Boxes shrink to fit 320px screens without overflow

Accessibility

The boxes are grouped in a fieldset with a visible legend, each box is named Digit 1 of 6 and so on, and they use inputmode="numeric". The message line is a polite live region described by every box, so errors are announced and read with the field. Boxes show an indigo border and ring on focus; buttons and links show a 2px outline. Transitions switch off under prefers-reduced-motion.

Customise it

Change --ot-brand, --ot-line and --ot-bad on .ot, and the number of boxes in the markup; the script counts them. In Tailwind edit the --color-ot-* theme colours and the h-[60px] max-w-[52px] box size.