Skip to content

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" />
</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.

ClassPart
.input-otpThe whole field
.input-otp-groupSlots that sit flush, sharing their edges
.input-otp-slotOne character
.input-otp-separatorA mark between groups
AttributeOnMeaning
data-pk-otp-valueA hidden inputReceives the joined code on every change
patternA slotCharacters allowed when pasting
aria-invalid="true"A slotMarks it as rejected

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.

Verification code

Sent to ada@example.com. Paste it and every slot fills at once.

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.

  • 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 — set inputmode="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-invalid and say what happened next to the field; wiping the input silently is how people give up.