OTP Input
The <x-wirekit:: 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 and Size
The OTP input sizes itself to the number of cells and does not stretch to fill its container. size sets how large each cell is, on the component heights: sm is 2rem square (--size-wk-sm), md (the default) 2.5rem (--size-wk-md), lg 3rem (--size-wk-lg). xl is 3.5rem, one more step of the same ladder above lg: calc(2 * var(--size-wk-lg) - var(--size-wk-md)), with its digits at --text-wk-2xl. A theme that resizes controls resizes the cells with them; the widths below are at the defaults.
On a touch screen every size is lifted to the 44px touch target already. Reach for lg where a finger works a screen that reports a mouse as its pointer, such as a till or a kiosk with one plugged in: there it is the smallest size that clears 44px. xl is for a screen where the code is the only thing to enter, such as a PIN pad or a lock screen. A card sized to fit the row can read the same two tokens the cell does. The widths quoted under Grouping a long code are for the default size.
To center the whole field in its container:
<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.
The cell keeps the key you typed even when the caret sits after its character, so a mistyped code never needs a deletion before the right character goes in.
A code filled in at once
A code that arrives in a cell all at once fills the row, the same way a paste does: the code a phone offers from a text message above its keyboard, a password manager that recognizes the field, or text an input assistant inserts. It spreads from the first cell whichever cell it landed in, keeps only what the alphabet accepts, and leaves the caret in the first cell it did not fill.
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.
Grouping a long code
A code of eight or more characters does not fit one row in the card it usually sits in, and where the row runs out is decided by the width alone, so it can fall after any cell: six and two in one card, five and three on a phone. A break in the middle of a code, at no boundary the code has, reads as a rendering fault to somebody copying it out of an email.
group says how many cells belong together. The groups do not break inside, so a row that fits stays one row and a row that does not breaks on a group boundary — eight cells with group="4" are one row on a desktop and 4+4 on a phone. The gap between groups is wider than the gap between cells, which is the separator; there is no character to read out.
Leave it off for the ordinary six-digit code: the row fits, and one row is what the reader is looking at in their email.
Grouping makes the row WIDER, by 0.5rem per group boundary. The cells sit 0.5rem apart and the groups 1rem apart, so eight cells measure 23.5rem ungrouped and 24rem as 4+4 — 376px against 384px. That is enough to make a row that fitted a card break once group is added, which is exactly what happened in the application that reported the feature: the card had been sized against the narrower row. Size a card against the grouped width, not the plain one.
Aligning the row
The cells pack to the leading edge of the control, which is right until the card around them is centered — and a one-time code almost always sits in one, under a centered heading and above a centered button. justify moves the row inside the control's own width.
Check your email
We sent a six-digit code to your inbox.
'start' is the default, so nothing you already have moves. 'center' and 'end' are the other two.
Filling the width
justify="stretch" makes the cells share the control's width, so the row ends flush with the text above it and the button below, as on a lock screen where everything in the card runs edge to edge.
Enter your PIN to unlock the till.
Every cell keeps its square and grows from the touch target, or from its size when that is larger, up to twice its size. On a card so wide that every cell has reached that cap, the cells keep that size and the width they leave goes between them, so the first and the last cell still stand at the edges, as four lg cells in a dialog of the standard width do. With group, each group takes its share of the width by its number of cells, and a row too narrow for the next group still breaks at the group boundary, each group then flush on a line of its own. A container that takes its width from its content (w-fit, a popover) is as wide as the row at its size, and the cells fill it from there.
It moves the cells, not the label. The label, the hint and the error message sit above the row and keep the alignment of the text around them — so in a card that is not centered, justify="center" gives you a centered row of cells under a left-aligned label. That is usually not what you want: center the card's text (or the field's own wrapper) and let both follow. The preview above does exactly that.
A class on the tag will not do this. The class attribute lands on the component's outer wrapper, never on the row of cells, so a centering utility written at the call site styles an element that already spans the full width and leaves the row where it was. Before this prop existed the only way through was a stylesheet rule reaching into the component's internal markup — which works until the markup moves, and then stops working without saying so.
A wrapped code centers both of its rows: a flex line is justified on its own, so the second row sits under the middle of the first rather than under its left edge. That is the case this prop matters most for, and it is the one a narrow phone produces.
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.
The binding works in both directions. When your component sets the property, the boxes show the new value. Emptying it after a check, the way a PIN pad does after every attempt, clears the boxes and moves focus to the first one if it was in the row, and the next code the reader types is announced as complete again. A value your component sets is never announced itself, because the reader did not enter it.
Bind the code with wire:model. An x-model on the tag is dropped, because the code reaches Alpine through none of the boxes: see An x-model on a WireKit component.
// 1. After each attempt, right or wrong, the boxes empty for the next one.
public function check(): void
{
$this->verify($this->pin);
$this->pin = '';
}
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. --}}
<x-wirekit::otp-input
name="code"
:length="6"
label="Verification code"
hint="We check the code as soon as you enter the last digit."
x-on:wirekit:otp-complete="$wire.verify($event.detail.value)"
/>
The listener can sit on the tag, as here, or on any element around the field: the event bubbles.
$event. 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 the same, and the event goes unheard.
When a correct code takes the reader to another page, checking it at the last digit changes the page without a button press, so say so before they type, as the hint above does. WCAG 3.2.2 asks for that warning whenever filling in a field changes the context on its own. A button that submits the code needs none.
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"
hint="We check the code as soon as you enter the last digit."
optimistic="verifyCode"
/>
The code goes out at the last digit here as well, so the hint says so for the same reason.
Load wirekit-optimistic. alongside whichever bundle you already use — below it, in your layout:
@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}" defer></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 |
|---|---|---|---|
required |
bool | false |
Marks the group as required. Emits aria-required="true" on the role="group" wrapper and renders the label indicator (*). The boxes carry no native required, which would read one box rather than the code. In a form, an empty required code stops the submit as a native required field does: the browser's own message shows under the boxes, in the reader's language, and the focus moves to the first empty box when it is the first empty field. |
label |
string|null |
null |
Label text above the input group |
hint |
string|null |
null |
Help text below the input group |
help |
string|null |
null |
An explanation of the field, shown in a tooltip from a question mark beside the label and read as the field's description. Needs a label to stand beside. See Help beside the label |
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}-digit-0, {id}-digit-1, etc.). A <label for="{id}-digit-0"> of yours names the first box, as the label prop's own does |
length |
int |
6 |
Number of character inputs |
group |
int|null |
null |
Cells per group. The groups break against each other rather than inside, so a long code stays one row while it fits and splits evenly when it does not. Values from 2 up to length |
justify |
string |
'start' |
Where the row of cells sits inside the control: 'start', 'center' or 'end', or 'stretch' to make the cells share its width. A class on the tag cannot do this — it lands on the outer wrapper, not on the row — see Aligning the row |
size |
string |
'md' |
Cell size: 'sm' (2rem), 'md' (2.5rem), 'lg' (3rem) or 'xl' (3.5rem). A touch screen lifts every size to the 44px touch target; lg and xl clear it on a mouse-driven screen too. See Width and Size |
masked |
bool |
false |
Use type="password" for each input |
autofocus |
bool |
false |
Focus the first cell on load. Reach for it on a single-purpose screen — a code-entry page the reader was redirected to, where the cells are the only thing they came to operate. Leave it off when the field shares a page with anything the reader may be in the middle of. |
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. |
announceError |
bool|null | null |
Render the error message in an aria-live="polite" region so a validation error that appears after render reaches a screen reader. Precedence: this prop, then the parent <x-wirekit::form :announce-errors>, then config('wirekit.a11y.announce_error'). Set false when the page runs its own live region. |
A class on the tag goes on the outer wrapper, and so does every attribute that has no place of its own: style, x-show, a listener, wire:keydown. dusk and any data-* attribute go on the role="group" element, the one a reader and a browser test both meet as the control, and wire:model goes on the hidden field the form submits. With optimistic, the wrapper listens for the completion itself, so a listener for wirekit:otp-complete written on the tag sits on the role="group" element instead, where the event is dispatched.
Slots
| Slot | Description |
|---|---|
labelAfter |
Markup after the label and its help question mark, in the same row and outside both, such as a badge that says a setting is not decided yet. See A Mark After the Label. |
Accessibility
- Each individual input has
autocomplete="one-time-code", which anautocompleteof yours replaces on every box, and aninputmodethat follows the alphabet —numericfor a digit code,textwhen it contains letters, so the mobile keyboard offers the keys the code actually needs - An
enterkeyhintwritten on the tag goes to every box, where the on-screen keyboard reads it - The cells are wrapped in a
<div>withrole="group"and anaria-label(the label), grouping them semantically. A<fieldset>with a<legend>would group them too, and the difference is how often the reader hears it: a legend is announced before every field in the group, so a six-digit code repeats the label six times before the person has typed anything. A group label is spoken once, on entry, and each cell then says which digit it is - Each cell is named by its place, "Digit 2 of 6". The visible label points at the first cell, so with a
labelthat cell carries the label's words as well, "Verification Code, digit 1 of 6": a reader who uses speech input says the words on screen and reaches the field. Without alabelthe first cell is "Digit 1 of 6" like the rest - 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, and so does a code filled in at once by the platform, from a text message or a password manager
- Focus management is fully automatic — users never need to manually tab between cells
autofocusputs the caret in the first cell on load. It is the plain HTML attribute on that one cell, so it works before any script runs; it is off by default, because moving focus on a page somebody is already reading takes them somewhere they did not ask to go. On a dedicated code screen the opposite is true — the reader arrived expecting to type, and without it their first keystroke goes nowhere
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 |
Cell background |
--color-wk-border-strong |
Default border color |
--color-wk-border-error |
Border color on error |
--border-wk-width |
Border width of a cell |
--shadow-wk-sm |
Shadow of a cell |
--color-wk-text |
Digit color |
--font-wk-sans / --font-wk-heading-weight |
Font family and weight of the digits |
--color-wk-ring / --ring-wk-width |
Focus ring color and width |
--size-wk-sm / --size-wk-md / --size-wk-lg |
Width and height of a sm, md and lg cell. An xl cell is calc(2 * var(--size-wk-lg) - var(--size-wk-md)), one step above lg |
--text-wk-md / --text-wk-lg / --text-wk-xl / --text-wk-2xl |
Digit size of a sm, md, lg and xl cell |
--radius-wk-sm / --radius-wk-md / --radius-wk-lg |
Corner of a sm cell, of a md and a lg cell, and of an xl cell |
--transition-wk-duration |
Focus/error transition speed |
--opacity-wk-disabled |
Dimmed state when disabled |
Customization
Override defaults in config/:
'components' => [
'otp-input' => ['length' => 6, 'masked' => false, 'justify' => 'start', 'size' => 'md'],
],
Further Reading
- MDN:
inputmode - MDN:
autocomplete="one-time-code" - MDN: the
grouprole - WebAIM: Creating Accessible Forms
- Web OTP API (Chrome) — browser-level OTP autofill