Input OTP
<div class="input-otp"> <div class="input-otp-group" role="group" aria-label="One-time code"> <input class="input-otp-slot" aria-label="Digit 1" /> <input class="input-otp-slot" aria-label="Digit 2" /> <input class="input-otp-slot" aria-label="Digit 3" /> <input class="input-otp-slot" aria-label="Digit 4" /> <input class="input-otp-slot" aria-label="Digit 5" /> <input class="input-otp-slot" aria-label="Digit 6" /> </div></div><div class="input-otp"> <div class="input-otp-group" role="group" aria-label="One-time code"> <input class="input-otp-slot" aria-label="Digit 1" /> <input class="input-otp-slot" aria-label="Digit 2" /> … </div></div>Every slot is a real <input>. The code reaches a form with no JavaScript at all — the runtime
only adds what HTML cannot say: advancing between slots, backspacing into the one before, and
spreading a pasted code across all of them at once.
| Class | Part |
|---|---|
.input-otp | The whole field |
.input-otp-group | Slots that sit flush, sharing their edges |
.input-otp-slot | One character |
.input-otp-separator | A mark between groups |
| Attribute | On | Meaning |
|---|---|---|
data-pk-otp-value | A hidden input | Receives the joined code on every change |
pattern | A slot | Characters allowed when pasting |
aria-invalid="true" | A slot | Marks it as rejected |
With a separator
Section titled “With a separator”<div class="input-otp"> <div class="input-otp-group" role="group" aria-label="One-time code, first half"> <input class="input-otp-slot" aria-label="Digit 1" /> <input class="input-otp-slot" aria-label="Digit 2" /> <input class="input-otp-slot" aria-label="Digit 3" /> </div>
<span class="input-otp-separator" aria-hidden="true"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M5 12h14"></path> </svg> </span>
<div class="input-otp-group" role="group" aria-label="One-time code, second half"> <input class="input-otp-slot" aria-label="Digit 4" /> <input class="input-otp-slot" aria-label="Digit 5" /> <input class="input-otp-slot" aria-label="Digit 6" /> </div></div>In a form
Section titled “In a form”Slots submit one field each, which is rarely what a server wants. A hidden input carrying
data-pk-otp-value receives the joined code, so the form posts code=1234.
<form class="field-group" style="width: fit-content" onsubmit="return false"> <div class="field"> <span class="label" id="otp-label">Verification code</span>
<div class="input-otp" aria-labelledby="otp-label"> <input type="hidden" name="code" data-pk-otp-value /> <div class="input-otp-group"> <input class="input-otp-slot" aria-label="Digit 1" /> <input class="input-otp-slot" aria-label="Digit 2" /> <input class="input-otp-slot" aria-label="Digit 3" /> <input class="input-otp-slot" aria-label="Digit 4" /> </div> </div>
<p class="field-description">Sent to ada@example.com. Paste it and every slot fills at once.</p> </div>
<button class="btn" type="submit">Verify</button></form>Events
Section titled “Events”document.addEventListener("pk:otp:change", (event) => { if (event.detail.complete) submit(event.detail.value);});Set dir="rtl" on the page or on any subtree — slots round on the correct outer edges and the
arrow keys follow the reading direction. See the button page for a live
example.
Accessibility
Section titled “Accessibility”- Every slot needs a name. “Digit 1”, “Digit 2” — a screen reader otherwise announces six identical unlabelled fields.
- Group them and name the group, so the field is announced as one thing rather than as scattered inputs.
inputmode="numeric"brings up the number pad on a phone, and is set for you unless you say otherwise — setinputmode="text"for an alphanumeric code.- Paste works from any slot, which matters: people paste from a message app, not always into the first box.
- Do not clear every slot on a wrong code. Mark them
aria-invalidand say what happened next to the field; wiping the input silently is how people give up.