Skip to main content
WireKit
Copy for LLM

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

6-Digit OTP

Custom Length

4-Digit Code

Masked Input

Masked Characters

When masked is true, each input uses type="password" instead of type="text", displaying dots instead of the typed characters.

Error State

OTP input with an error message

Invalid code. Please try again.

With Hint

With Hint Text

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:

Alphanumeric code

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:

Anything your codes actually contain

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 lowercase a and stores A. 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-E6G8 fills 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
09 / az (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.

Optimistic one-time code — accepted, refused, and a slow answer

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 an inputmode that follows the alphabet — numeric for a digit code, text when 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 via aria-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+V paste-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

Was this page helpful?

Voting requires cookies or local storage. What we store