Input OTP
A segmented input for one-time passcodes with paste support.
Preview
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Verification code') }}</label>
<x-ui.input-otp name="otp" :length="6" />
</div>
Installation
php artisan ui:add input-otp
Note
This component ships an Alpine behavior module at
resources/js/ui/input-otp.js. Import it once from your bundle so it registers on alpine:init:
import './input-otp.js';
Registry contract
php artisan ui:add input-otp
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/input-otp.blade.php -
resources/js/ui/input-otp.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Input OTP (`input-otp`)
A segmented input for one-time passcodes with paste support.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add input-otp
```
## Usage
```blade
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Verification code') }}</label>
<x-ui.input-otp name="otp" :length="6" />
</div>
```
## Props
- `name` (mixed|null, default `null`) — Form field name; inherited from a wrapping field via @aware or set directly. Names the hidden input that carries the joined code.
- `length` (int, default `6`) — Number of digit cells rendered.
- `size` (default|lg, default `default`) — Cell size: default is 40px with small digits; lg is 48px with large digits, for a screen whose one task is entering the code.
- `groups` (int, default `1`) — Splits the cells into this many groups with a short dash between them, so a six-digit code reads as 3 + 3 with groups="2". Clamped to 1..length.
## Use when
- Use when formatting, validation, or constrained entry meaningfully reduces errors, such as OTP, phone, currency, password, or numeric entry.
- Collecting a fixed-length one-time passcode or verification code, one digit per cell.
- Wanting paste support so a code copied from an SMS or email fills every cell at once.
## Avoid when
- Do not add specialized behavior if a plain field would be simpler and more forgiving.
- The code has a variable length or is not purely numeric; use input with inputmode instead.
- A single free-text value; use input instead.
## Anti-patterns
- Adding formatting behavior that makes correction harder
## Rules
- Use the `<brok:input-otp>` tag (or `<x-ui.input-otp>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/input-otp
- Registry JSON (files, props, contract): https://brokui.dev/r/open/input-otp.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Grouped Large
{{-- A sign-in code step: large cells split 3 + 3, centred under a centred label. --}}
<div class="flex flex-col items-center gap-3 text-center">
<p id="otp-grouped-label" class="text-sm font-medium text-foreground">{{ __('Enter the 6-digit code from the app') }}</p>
<x-ui.input-otp name="otp" :length="6" size="lg" :groups="2" aria-labelledby="otp-grouped-label" />
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | mixed | null | null | Form field name; inherited from a wrapping field via @aware or set directly. Names the hidden input that carries the joined code. |
| length | int | 6 | Number of digit cells rendered. |
| size | default | lg | default | Cell size: default is 40px with small digits; lg is 48px with large digits, for a screen whose one task is entering the code. |
| groups | int | 1 | Splits the cells into this many groups with a short dash between them, so a six-digit code reads as 3 + 3 with groups="2". Clamped to 1..length. |
Slots
default— Unused; the cells render from the length prop only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Cells render statically in a Blade loop, not an Alpine x-for, so they are never re-created on keystroke and focus/caret stay stable while typing.
- Typing a digit advances focus to the next cell; Backspace on an empty cell moves focus back and clears the previous digit.
- Pasting a full code fills every cell at once and mirrors the joined value into the hidden input.
- Groups change only the layout: typing, Backspace and paste move across the dash as if the cells were one row.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Collect constrained information with error-reducing formatting.
Use when
- Use when formatting, validation, or constrained entry meaningfully reduces errors, such as OTP, phone, currency, password, or numeric entry.
- Collecting a fixed-length one-time passcode or verification code, one digit per cell.
- Wanting paste support so a code copied from an SMS or email fills every cell at once.
Avoid when
- Do not add specialized behavior if a plain field would be simpler and more forgiving.
- The code has a variable length or is not purely numeric; use input with inputmode instead.
- A single free-text value; use input instead.
Use instead
- Plain text or number field with validation
Anti-patterns
- Adding formatting behavior that makes correction harder
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- ArrowLeft ArrowRight
- Focus
managed
- Each cell has an aria-label announcing its position ("Digit 1", "Digit 2", and so on), so a screen reader tracks progress through the code.
- The group carries aria-invalid and aria-describedby when a wrapping field reports a validation error.
- The dash between groups is aria-hidden; screen readers still hear Digit 1 to Digit 6.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="input-otp-{{ $record->id }}">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Verification code') }}</label>
<x-ui.input-otp name="otp" :length="6" />
</div>
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:input-otp
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware so this
composite self-wires old()/aria-invalid/aria-describedby off the shared
$errors bag. Inert with no field ancestor and no own `name` —
fully backward-compatible. --}}
@aware([
'name' => null,
])
@props([
'name' => null,
'length' => 6,
// default | lg: lg is for a page whose one task is entering the code.
'size' => 'default',
// Number of groups the cells split into, with a dash between them (2 gives 3 + 3 for six digits).
'groups' => 1,
])
@php
$awareName = $name ?? null;
$fieldName = $awareName ?? $attributes->get('name');
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
// old() repopulates the entered code after a failed redirect-back; clamp to the
// expected length so a longer flashed value can't overflow the cells.
$resolvedValue = filled($fieldName) ? (string) old($fieldName, '') : '';
$resolvedValue = $resolvedValue !== '' ? mb_substr($resolvedValue, 0, (int) $length) : '';
// aria-describedby (on the group) points at the field's error region only when
// inheriting from a field AND there is an error to announce.
$describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;
$length = max(1, (int) $length);
$groups = max(1, min($length, (int) $groups));
$perGroup = (int) ceil($length / $groups);
$cellSize = $size === 'lg' ? 'size-12 text-lg' : 'size-10 text-sm';
@endphp
{{--
Input OTP (spec §19). Cells are rendered statically (a Blade loop, not an
Alpine x-for) so they are never re-created on keystroke — that keeps focus and
the caret stable while typing. The behavior layer reads the cells from the DOM
and mirrors the combined value into the hidden input.
--}}
<div
x-data="uiInputOtp({ length: {{ $length }} })"
{{-- The joined code is modelable: `x-model` (Alpine) and `wire:model`
(Livewire binds through the same x-model contract) attach here. --}}
x-modelable="model"
x-init="sync()"
data-slot="input-otp"
data-size="{{ $size === 'lg' ? 'lg' : 'default' }}"
dir="ltr"
role="group"
@if ($hasBagError) data-invalid="true" aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->except(['name'])->merge(['class' => 'flex items-center gap-2']) }}
>
@for ($i = 0; $i < $length; $i++)
@if ($i > 0 && $i % $perGroup === 0)
<span data-slot="input-otp-separator" class="h-px w-3 shrink-0 bg-border" aria-hidden="true"></span>
@endif
<input
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="1"
data-slot="input-otp-slot"
data-index="{{ $i }}"
aria-label="{{ __('Digit :n', ['n' => $i + 1]) }}"
@if (($digit = mb_substr($resolvedValue, $i, 1)) !== '') value="{{ $digit }}" @endif
@if ($hasBagError) aria-invalid="true" @endif
@input="onInput($event, {{ $i }})"
@keydown="onKey($event, {{ $i }})"
@paste="onPaste($event)"
@focus="$event.target.select()"
class="{{ $cellSize }} rounded-md border border-input bg-background text-center font-medium tabular-nums text-foreground shadow-xs transition-[color,box-shadow] focus:outline-none focus:border-ring focus:ring-2 focus:ring-ring motion-reduce:transition-none"
/>
@endfor
@if (filled($fieldName))
<input type="hidden" name="{{ $fieldName }}" :value="value" />
@endif
</div>
/**
* Input OTP behavior (spec §19 behavior layer).
*
* A segmented one-time-passcode input: each cell holds a single digit. Typing
* advances focus, Backspace clears and steps back, arrow keys move between
* cells, and pasting a code fills all cells at once. Cells are uncontrolled
* (static DOM); the combined value is read from the DOM and mirrored into the
* hidden input via `value`.
*
* Self-registers on `alpine:init` so import order does not matter — import this
* file once from your bundle (e.g. resources/js/ui/index.js).
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiInputOtp', (config = {}) => ({
length: config.length ?? 6,
value: '',
root: null,
init() {
// `$el` is whichever element the running expression sits on — the
// cell inside an @input handler — so the root is pinned here, where
// `$el` is reliably the component root.
this.root = this.$el;
},
cells() {
return Array.from((this.root ?? this.$root).querySelectorAll('[data-slot="input-otp-slot"]'));
},
/** Write `text` into the cells from index `from`, then focus the cell after the last digit. */
fill(text, from = 0) {
const cells = this.cells();
const digits = text.replace(/\D/g, '').slice(0, this.length - from);
for (let j = 0; j < digits.length; j++) {
cells[from + j].value = digits[j];
}
this.sync();
this.focusCell(Math.min(from + digits.length, this.length - 1));
},
/**
* Modelable value (`x-modelable="model"` on the root): the joined code.
* An inbound value (Livewire, `x-model`) is spread over the cells.
*/
get model() {
return this.value;
},
set model(v) {
const text = String(v ?? '').replace(/\D/g, '').slice(0, this.length);
if (text === this.value) return;
this.cells().forEach((cell, i) => {
cell.value = text[i] ?? '';
});
this.sync();
},
/** Recompute the combined value from the live cells. */
sync() {
this.value = this.cells().map((c) => c.value).join('');
},
focusCell(i) {
const cell = this.cells()[i];
if (cell) {
cell.focus();
cell.select && cell.select();
}
},
onInput(e, i) {
const digits = e.target.value.replace(/\D/g, '');
// A one-time-code autofill (or a multi-character IME commit) lands in
// one cell as a whole; spread it across the cells from here.
if (digits.length > 1) {
e.target.value = '';
this.fill(digits, i);
return;
}
e.target.value = digits;
this.sync();
if (digits && i < this.length - 1) {
this.focusCell(i + 1);
}
},
onKey(e, i) {
if (e.key === 'Backspace' && !e.target.value && i > 0) {
e.preventDefault();
const prev = this.cells()[i - 1];
if (prev) {
prev.value = '';
}
this.sync();
this.focusCell(i - 1);
} else if (e.key === 'ArrowLeft' && i > 0) {
e.preventDefault();
this.focusCell(i - 1);
} else if (e.key === 'ArrowRight' && i < this.length - 1) {
e.preventDefault();
this.focusCell(i + 1);
}
},
onPaste(e) {
e.preventDefault();
this.cells().forEach((cell) => { cell.value = ''; });
this.fill(e.clipboardData.getData('text') || '');
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0