OTP Input
The <x-wirekit::otp-input> component renders a row of individual single-character inputs for entering one-time passwords or verification codes. It handles auto-advance on input, paste-to-fill, and backspace navigation automatically via Alpine.js.
Basic Usage
Custom Length
Masked Input
When masked is true, each input uses type="password" instead of type="text", displaying dots instead of the typed characters.
Error State
Invalid code. Please try again.
With Hint
Enter the 6-digit code sent to your phone.
Width
The OTP input auto-sizes based on the number of digits and the size prop. It does not stretch to fill its container — it has a natural fixed width. To center it:
<div class="flex justify-center">
<x-wirekit::otp-input label="Verification Code" name="code" />
</div>
Correcting a code
A filled cell overwrites. Focus one — by clicking it or tabbing to it — and its character is selected, so typing replaces it and moves to the next cell exactly as if the row were empty. Correcting a mistyped code is one pass from the left, with no deletions.
That is worth stating because the opposite is what a row of single-character inputs does by
default: maxlength="1" is already satisfied by the character in the cell, so with the caret
sitting after it the browser simply refuses the keystroke. Nothing happens, and the reader has
to backspace every cell before retyping it.
Backspace still works the way it did: it clears the cell, and on an already-empty cell it steps back and clears the previous one — so a single wrong character costs one press.
Alphabet
By default a cell accepts digits. alphabet replaces that set with yours — whatever it is. It is a statement about your codes, not a recommendation from WireKit, and the component has no opinion about what belongs in one.
The example below drops the ambiguous pairs — no I against 1, no O against 0 — because a code that survives being read aloud over the phone usually should. That is a good default for a recovery sheet and it is not a rule:
Letters and digits, as printed on your recovery sheet.
Your backend decides the alphabet, so the component takes it as given. Lowercase, symbols, non-ASCII — all of it works, and the pattern follows:
Lowercase, digits and symbols — exactly the set your system issues.
One prop drives everything the cell does with a character: which keystrokes are accepted, what a paste keeps, the pattern used for validation, and whether the mobile keyboard opens numeric or alphabetic. They cannot drift apart.
Two behaviors follow from the alphabet rather than from extra props:
- Case is folded when the alphabet is unambiguous about it.
ABCDEF…accepts a lowercaseaand storesA. An alphabet containing both cases is taken at its word and stays case-sensitive, because there the two are different characters. - A paste keeps only what the alphabet allows. Pasting
A2C4-E6G8fills eight cells and drops the dash, so a code copied with its formatting still works.
Any character may appear in the alphabet, including -, ] and ^. Membership is tested by lookup rather than by a generated pattern, so nothing needs escaping at the call site.
Keyboard Behavior
| Key | Action |
|---|---|
Tab / Shift+Tab |
Move focus between cells. The cell's character is selected on arrival, so typing replaces it |
0–9 / a–z (whichever the alphabet allows) |
Type the character and advance to the next cell. A filled cell is overwritten |
Backspace |
Clear the current cell, then move focus to the previous one |
Delete |
Clear the current cell without moving focus |
ArrowLeft / ArrowRight |
Move focus between cells without changing values |
Ctrl+V / Cmd+V |
Paste — distributes characters across all cells, advancing focus |
Clicking a cell selects its character too, so correcting a code is one pass from the left with no deletions.
Form Submission
A hidden <input type="hidden"> holds the concatenated value of all individual inputs, submitted under the given name. This means Livewire's wire:model binds to the complete code string.
Knowing when the code is complete
A one-time code is usually submitted the moment the last box is filled, rather than by pressing a button. The field announces that moment as an event, so you can decide what it means:
{{-- 1. The event fires when the last box is filled — not on every keystroke. --}}
<div x-on:wirekit:otp-complete="$wire.verify($event.detail.value)">
<x-wirekit::otp-input name="code" :length="6" label="Verification code" />
</div>
$event.detail.value carries the whole code. The event fires once per completion: filling the last box announces it, and typing on in a full field does not announce again. Clearing a box and retyping it is a new completion, so a corrected code still submits.
Listening is optional — a field nobody listens to behaves exactly as before.
Optimistic UI
Pass the name of the Livewire method the field should call and the code is sent the moment it is complete, shown as saving while it goes:
<x-wirekit::otp-input
name="code"
:length="6"
label="Verification code"
optimistic="verifyCode"
/>
Load wirekit-optimistic.js alongside whichever bundle you already use — below it, in your layout:
@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}"></script>
Try it
The demo below runs the real path: the change shows immediately, the outline says it is provisional, and the server's answer either confirms it silently or takes it back.
The <livewire:demos.…> wrapper above exists only on this site — it supplies the demo
methods so the page can show a real round trip. The block under it is what you write.
It sends when the code is complete, not on every keystroke — the same moment the completion event fires.
A refusal does not clear the boxes. That matters more here than in any other field: a one-time code often expires within minutes, so clearing it can mean the reader has nothing left to retype. The code stays, and you are told two things — that it was not accepted, and that it is still there.
The field is not marked invalid. aria-invalid means this value is wrong, and a save that failed on the network says nothing about the code.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string|null |
null |
Label text above the input group |
hint |
string|null |
null |
Help text below the input group |
error |
string|null |
null |
Error message (also reads from $errors) |
name |
string|null |
null |
Form field name (hidden input holds the full code) |
id |
string|null |
auto-generated | Base id (individual inputs get {id}-0, {id}-1, etc.) |
length |
int |
6 |
Number of character inputs |
masked |
bool |
false |
Use type="password" for each input |
alphabet |
string |
'0123456789' |
Characters a cell accepts. Drives the keystroke filter, the paste filter, the pattern and the mobile keyboard together |
disabled |
bool |
false |
Disabled state |
scope |
string|null |
null |
Scoped personalization key |
optimistic |
string|null |
null |
Livewire method to call once the last box is filled, showing the code as submitting. A refusal keeps the digits so they need not be retyped. See Optimistic UI. |
optimisticArgs |
array |
[] |
Extra arguments appended to the optimistic action call, after the new value — the row this control belongs to. |
Accessibility
- Each individual input has
autocomplete="one-time-code", and aninputmodethat follows the alphabet —numericfor a digit code,textwhen it contains letters, so the mobile keyboard offers the keys the code actually needs - The group is wrapped in a
<fieldset>with<legend>(the label), grouping all cells semantically - Each cell has
aria-label="Digit N of M"for screen reader context - Error state sets
aria-invalid="true"on all cells and links to the error message viaaria-describedby - Paste is supported and distributes characters across all cells
- Focus management is fully automatic — users never need to manually tab between cells
Keyboard Interaction
See Keyboard Behavior above — the full key table lives there.
Pitfalls
- Don't pre-fill an OTP value on the server. OTPs are entered, not displayed — pre-filling defeats the purpose and trains users to ignore the field.
- Don't disable paste. Users on mobile copy OTPs from SMS — the component supports
Cmd/Ctrl+Vpaste-distribution and removing it is a UX regression.
Design Tokens
| Token | Purpose |
|---|---|
--color-wk-bg-input |
Individual input background |
--color-wk-border-strong |
Default border color |
--color-wk-border-error |
Border color on error |
--color-wk-text |
Input text color |
--color-wk-accent |
Border color on focus |
--color-wk-ring |
Focus ring color |
--size-wk-md |
Input width and height |
--radius-wk-md |
Border radius per cell |
--text-wk-lg |
Font size for the digit character |
--transition-wk-duration |
Focus/error transition speed |
--opacity-wk-disabled |
Dimmed state when disabled |
Customization
Override defaults in config/wirekit.php:
'components' => [
'otp-input' => ['length' => 6, 'masked' => false],
],
Further Reading
- MDN:
inputmode - MDN:
autocomplete="one-time-code" - MDN:
<fieldset>and<legend> - WebAIM: Creating Accessible Forms
- Web OTP API (Chrome) — browser-level OTP autofill