<x-widget.otp>
OTP
One-time passwords in one box per character, digits or letters, with a pasted code filling them all. Filled, outline or underline boxes, groups with a dash between, and masked for a PIN. Submits one value. Works with Livewire wire:model.
php artisan larawell:add otp
Usage
Livewire
In a Livewire component, bind with wire:model; no name is needed. The boxes fill one hidden input, and that's what's bound, so the property holds the whole code (123456). When the last box is filled the component fires otp-complete, which Alpine (bundled with Livewire) can hand to a method: no Verify button needed. Set the property to '' in PHP, after a wrong code, and the boxes empty.
<div x-on:otp-complete="$wire.verify()">
<x-widget.otp label="Code we sent you" :length="6" wire:model="code" />
</div>
Examples
Otp
One box per digit. Typing moves to the next box, Backspace to the previous, and a pasted code fills them all.
Show code Hide code
<x-widget.otp name="otp" label="One-time password" />
Box styles
variant is filled (default), outline or underline. group="3" adds a dash between groups of boxes. masked shows dots, for a PIN. alphanumeric takes letters too, shown in capitals. length sets how many boxes.
Show code Hide code
<div class="grid gap-8 sm:grid-cols-2">
<x-widget.otp name="otp_outline" label="Outline, grouped" variant="outline" group="3" />
<x-widget.otp name="otp_underline" label="Underline" variant="underline" placeholder="" />
<x-widget.otp name="pin" label="PIN" :length="4" masked placeholder="" class="max-w-60" />
<x-widget.otp name="invite" label="Invite code" alphanumeric variant="outline" placeholder="" />
</div>
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.otp>
| Prop | Default | Description |
|---|---|---|
| name |
null
|
What it submits as; its error and old input are found under it. Optional with wire:model, which then names it. |
| id |
null
|
Defaults to one made from the name (or the wire:model property). |
| length |
6
|
How many boxes. |
| label |
null
|
Shown above the field, and its name for screen readers. |
| value |
null
|
The value to show. Old input wins after a failed submit; with wire:model and no value, the bound Livewire property. |
| placeholder |
'0'
|
Shown in each empty box. |
| error |
null
|
An error message of your own; otherwise the validation error for the name, from the session or Livewire. |
| bag |
'default'
|
Which error bag to read the error from. |
| disabled |
false
|
Greyed out: it can't be changed. |
| autofocus |
false
|
Focuses the first box when the page loads. |
| variant |
'filled'
|
filled, outline or underline. |
| group |
null
|
Splits the boxes into groups of this size (3: 123 456). |
| masked |
false
|
Hides each character as it's typed, like a password. |
| alphanumeric |
false
|
Letters and digits (shown in capitals) instead of digits only. |
Source
What larawell:add otp writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
Field, and the theme and base CSS.
resources/views/components/widget/otp/index.blade.php Show
@props([
// What it submits as; its error and old input are found under it. Optional with wire:model, which then names it.
'name' => null,
// Defaults to one made from the name (or the wire:model property).
'id' => null,
// How many boxes.
'length' => 6,
// Shown above the field, and its name for screen readers.
'label' => null,
// The value to show. Old input wins after a failed submit; with wire:model and no value, the bound Livewire
// property.
'value' => null,
// Shown in each empty box.
'placeholder' => '0',
// An error message of your own; otherwise the validation error for the name, from the session or Livewire.
'error' => null,
// Which error bag to read the error from.
'bag' => 'default',
// Greyed out: it can't be changed.
'disabled' => false,
// Focuses the first box when the page loads.
'autofocus' => false,
// filled, outline or underline.
'variant' => 'filled',
// Splits the boxes into groups of this size (3: 123 456).
'group' => null,
// Hides each character as it's typed, like a password.
'masked' => false,
// Letters and digits (shown in capitals) instead of digits only.
'alphanumeric' => false,
])
@php
$length = max(1, (int) $length);
// Resolved twice: once for the base id the boxes share (otp-0, otp-1 …), once for the first box,
// which the frame keys its label and messages off.
$baseId = \App\View\Widget\FormField::make($name, $id, null, idPrefix: 'otp', attributes: $attributes)->id;
$field = \App\View\Widget\FormField::make($name, "{$baseId}-0", $errors ?? null, $error, $bag, attributes: $attributes);
// alphanumeric takes letters too (shown in capitals), for codes like A7K2QX; otherwise digits only.
$allowed = $alphanumeric ? '/[^A-Za-z0-9]/' : '/\D/';
$value = strtoupper(substr((string) preg_replace($allowed, '', (string) $field->old($value)), 0, $length));
$digits = array_map(fn (int $i): string => $value[$i] ?? '', range(0, $length - 1));
$aria = $field->aria($attributes);
// group="3" puts a dash after every third box (123 – 456), which is easier to read and copy.
$group = $group !== null && (int) $group > 0 && (int) $group < $length ? (int) $group : null;
$variants = [
// Hover and focus turn the border primary, like the other fields.
'filled' => 'bg-field rounded-[14px] border border-transparent hover:border-primary focus:border-primary',
'outline' => 'border-line-strong rounded-[14px] border bg-transparent hover:border-primary focus:border-primary',
'underline' => 'border-line-strong rounded-none border-0 border-b-2 bg-transparent hover:border-primary focus:border-primary',
];
$look = $variants[$variant] ?? $variants['filled'];
@endphp
<x-widget.field :required="$attributes->has('required')" :id="$field->id" :label="$label" :error="$field->errors" :disabled="$disabled" bare :class="$attributes->get('class')">
{{-- The boxes have no name; the hidden input carries the joined code on submit, with the caller's attributes. --}}
{{-- dir="ltr": a code reads left to right in every language, so the boxes and the arrow keys do too. --}}
<div data-otp dir="ltr" @if ($alphanumeric) data-alphanumeric @endif role="group" aria-label="{{ $label ?? 'One-time code' }}" class="flex w-full items-start gap-2 sm:gap-3">
@foreach ($digits as $i => $digit)
@if ($group && $i > 0 && $i % $group === 0)
<span aria-hidden="true" class="text-muted grid shrink-0 place-items-center self-center text-xl">–</span>
@endif
<div class="aspect-square max-w-14 min-w-0 flex-1">
<input
type="{{ $masked ? 'password' : 'text' }}"
id="{{ $baseId }}-{{ $i }}"
value="{{ $digit }}"
placeholder="{{ $placeholder }}"
inputmode="{{ $alphanumeric ? 'text' : 'numeric' }}"
@unless ($alphanumeric) pattern="[0-9]*" @endunless
autocomplete="{{ $i === 0 && ! $masked ? 'one-time-code' : 'off' }}"
aria-label="{{ $alphanumeric ? 'Character' : 'Digit' }} {{ $i + 1 }} of {{ $length }}"
@if ($alphanumeric) autocapitalize="characters" @endif
data-otp-box
@disabled($disabled)
@if ($autofocus && $i === 0) autofocus @endif
{{ $aria->class([
'text-foreground h-full w-full text-center text-[clamp(18px,5vw,24px)] font-medium uppercase outline-none transition-colors placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-50',
$look,
'text-[clamp(28px,8vw,40px)] leading-none' => $masked,
'group-data-invalid/field:border-error group-data-invalid/field:bg-error/10 group-data-invalid/field:text-error group-data-invalid/field:hover:border-error group-data-invalid/field:focus:border-error',
]) }}
>
</div>
@endforeach
<input type="hidden" @if ($name) name="{{ $name }}" @endif value="{{ $value }}" data-otp-value @disabled($disabled) {{ $field->forwarded($attributes) }}>
</div>
</x-widget.field>
resources/js/widget/otp/index.js Show
// Behaviour for <x-widget.otp>: one box per character, typing and pasting across them. Delegated from `document`, so fields added later work without
// re-initialising. Client-side filtering is only for convenience; the Form Request must validate the same rules.
import { on, onLivewireMorph, typingIn } from '../field';
// --- OTP: one box per digit --------------------------------------------------------
const otpBoxes = (root) => [...root.querySelectorAll('[data-otp-box]')];
// Digits only, or letters and digits in capitals when the field is alphanumeric (A7K2QX).
const otpClean = (root, text) => ('alphanumeric' in root.dataset ? text.replace(/[^a-z0-9]/gi, '').toUpperCase() : text.replace(/\D/g, ''));
function otpSync(root) {
const boxes = otpBoxes(root);
const hidden = root.querySelector('[data-otp-value]');
const value = boxes.map((box) => box.value).join('');
hidden.value = value;
// Both events: tools like Alpine x-model and Livewire wire:model listen for `input`, plain forms for `change`.
hidden.dispatchEvent(new Event('input', { bubbles: true }));
hidden.dispatchEvent(new Event('change', { bubbles: true }));
if (value.length === boxes.length) {
// Hook for auto-submit: root.addEventListener('otp-complete', (e) => form.requestSubmit()).
root.dispatchEvent(new CustomEvent('otp-complete', { bubbles: true, detail: { value } }));
}
}
function otpFill(root, start, digits) {
const boxes = otpBoxes(root);
[...digits].slice(0, boxes.length - start).forEach((digit, i) => {
boxes[start + i].value = digit;
});
boxes[Math.min(start + digits.length, boxes.length - 1)].focus();
otpSync(root);
}
on('input', '[data-otp-box]', (event, box) => {
const root = box.closest('[data-otp]');
const index = otpBoxes(root).indexOf(box);
// Typing into a filled box: keep only the newly typed character.
// Paste and SMS autofill arrive as other input types with the whole code in the value.
let digits;
if (event.inputType === 'insertText') {
digits = otpClean(root, event.data ?? '');
if (!digits) {
box.value = otpClean(root, box.value).slice(0, 1);
return;
}
} else {
digits = otpClean(root, box.value);
}
box.value = '';
if (!digits) {
otpSync(root);
return;
}
otpFill(root, index, digits);
});
on('keydown', '[data-otp-box]', (event, box) => {
const root = box.closest('[data-otp]');
const boxes = otpBoxes(root);
const index = boxes.indexOf(box);
if (event.key === 'Backspace') {
event.preventDefault();
if (box.value) {
box.value = '';
} else if (index > 0) {
boxes[index - 1].value = '';
boxes[index - 1].focus();
}
otpSync(root);
} else if (event.key === 'ArrowLeft' && index > 0) {
event.preventDefault();
boxes[index - 1].focus();
} else if (event.key === 'ArrowRight' && index < boxes.length - 1) {
event.preventDefault();
boxes[index + 1].focus();
}
});
on('paste', '[data-otp-box]', (event, box) => {
event.preventDefault();
const root = box.closest('[data-otp]');
const digits = otpClean(root, event.clipboardData?.getData('text') ?? '');
if (digits) {
otpFill(root, otpBoxes(root).indexOf(box), digits);
}
});
on('focusin', '[data-otp-box]', (event, box) => box.select());
// Livewire: a render brings back the hidden value; the boxes follow it, unless they're being typed in.
onLivewireMorph((scope) => scope.querySelectorAll('[data-otp]').forEach((root) => {
if (typingIn(root)) {
return;
}
const value = root.querySelector('[data-otp-value]')?.value ?? '';
otpBoxes(root).forEach((box, i) => {
if (box.value !== (value[i] ?? '')) {
box.value = value[i] ?? '';
}
});
}));
app/View/Widget/ElementIds.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Container\Attributes\Scoped;
use LogicException;
/**
* Keeps element ids unique within one response, so labels, aria-describedby and #fragments
* always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
*/
#[Scoped]
final class ElementIds
{
/** @var array<string, true> */
private array $used = [];
/**
* Reserves an id for this response.
*
* A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
* id is one the caller chose and may reference from JS or CSS, so silently renaming it would
* break that reference; a duplicate throws instead, which surfaces in development and tests.
*/
public function claim(string $id, bool $explicit = false): string
{
if (!isset($this->used[$id])) {
return $this->reserve($id);
}
if ($explicit) {
throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
}
$suffix = 2;
while (isset($this->used["{$id}-{$suffix}"])) {
$suffix++;
}
return $this->reserve("{$id}-{$suffix}");
}
private function reserve(string $id): string
{
$this->used[$id] = true;
return $id;
}
}
app/View/Widget/FormField.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Contracts\Support\MessageBag;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\ComponentAttributeBag;
/**
* Server-side state of one form widget: its dot-notation key, a valid id, its validation
* messages and its old input. Every <x-widget.input.*> and the date picker resolve through
* here, so array names (items[0][date]) and named error bags behave the same everywhere.
*/
final class FormField
{
/**
* @param list<string> $errors
*/
private function __construct(
public readonly ?string $name,
public readonly string $id,
public readonly ?string $key,
public readonly array $errors,
// The property a wire:model or x-model attribute binds it to, if any.
public readonly ?string $bound = null,
) {}
/**
* @param mixed $errorBag the view's shared $errors (absent outside a web request)
* @param string|array<int, string>|null $error an explicit message from the caller; overrides the bag
*/
public static function make(
?string $name,
?string $id,
mixed $errorBag,
string|array|null $error = null,
string $bag = 'default',
string $idPrefix = 'field',
?ComponentAttributeBag $attributes = null,
): self {
// With no name, a Livewire or Alpine binding (wire:model="email") names the field. Its errors are filed under
// that property, and its id stays the same on every render, which Livewire's morph needs to keep the element
// (it matches elements by id: a random one makes it swap in a new field, dropping focus mid-typing).
$bound = $attributes === null ? null : self::boundTo($attributes);
$key = match (true) {
$name !== null && $name !== '' => self::key($name),
$bound !== null => self::key($bound),
default => null,
};
$messages = match (true) {
$error !== null => Arr::wrap($error),
$key !== null && $errorBag instanceof ViewErrorBag => self::messagesFor($errorBag->getBag($bag), $key),
default => [],
};
return new self(
$name,
app(ElementIds::class)->claim(
$id ?? ($key !== null ? self::idFrom($key) : $idPrefix.'-'.Str::random(6)),
explicit: $id !== null,
),
$key,
array_values(array_filter($messages, static fn (mixed $message): bool => is_string($message) && $message !== '')),
$bound,
);
}
/**
* The field's own messages, plus those Laravel files per item for a list of values: a 'tags.*' rule
* reports a bad second choice under tags.1, which a multiple select named tags must still show. Only
* numbered children count, so a field named address doesn't take errors meant for address[city].
*
* @return list<string>
*/
private static function messagesFor(MessageBag $bag, string $key): array
{
$items = array_filter(
$bag->getMessages(),
static fn (string $name): bool => preg_match('/^'.preg_quote($key, '/').'\.\d+$/', $name) === 1,
ARRAY_FILTER_USE_KEY,
);
return array_values(array_unique([...$bag->get($key), ...array_merge(...array_values($items))]));
}
/**
* items[0][date] → items.0.date and tags[] → tags: the key Laravel files errors and old input under.
*/
public static function key(string $name): string
{
return trim((string) preg_replace('/\[([^\]]*)\]/', '.$1', $name), '.');
}
/**
* The id a field named $name gets when it's the first of that name on the page, for links to it
* (the error summary). A later duplicate gets a -2 suffix, which links can't know about.
*/
public static function idFor(string $name): string
{
return self::idFrom(self::key($name));
}
/**
* The property a wire:model or x-model attribute (any modifiers) binds the field to; null without one.
*/
public static function boundTo(ComponentAttributeBag $attributes): ?string
{
return array_values(self::binding($attributes))[0] ?? null;
}
private static function idFrom(string $key): string
{
return trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $key), '-');
}
public function hasError(): bool
{
return $this->errors !== [];
}
public function errorId(): string
{
return $this->id.'-error';
}
public function infoId(): string
{
return $this->id.'-info';
}
/**
* Old input after a failed validation, falling back to the widget's value prop, or with none, to the bound Livewire
* property: a re-render then draws the field as it is, which Livewire morphs onto the page.
*/
public function old(mixed $default = null): mixed
{
if ($default === null) {
[$found, $live] = $this->fromLivewire();
$default = $found ? $live : null;
}
return $this->key === null ? $default : old($this->key, $default);
}
/**
* The bound property's value while Livewire renders the component that holds it: Livewire shares that component
* with every view as $__livewire. Livewire isn't a dependency; it's only looked for. [false, null] otherwise.
* $key reads inside it: a range bound to period reads period.start.
*
* @return array{0: bool, 1: mixed}
*/
public function fromLivewire(?string $key = null): array
{
$component = $this->bound === null ? null : view()->shared('__livewire');
return is_object($component) ? [true, data_get($component, $key === null ? $this->bound : "{$this->bound}.{$key}")] : [false, null];
}
/**
* The binding attribute as written (wire:model.live => period), to put on the inputs that carry the value: a range
* picker binds period.start and period.end with the same modifiers. Empty without one.
*
* @return array<string, string>
*/
public static function binding(ComponentAttributeBag $attributes): array
{
foreach ($attributes->getAttributes() as $attribute => $value) {
if (is_string($value) && $value !== '' && (str_starts_with($attribute, 'wire:model') || str_starts_with($attribute, 'x-model'))) {
return [$attribute => $value];
}
}
return [];
}
/**
* Whether a checkbox or switch renders ticked. An unticked box isn't in the request at all, so after a
* failed submit "no old value" means unticked, but only when that submit was this box's own form. A page
* with a second form (or a disabled box, which is never sent) would otherwise lose every `checked`.
*
* @param bool $alwaysSent it has an unchecked-value, so its form always sends something under its name
* @param mixed $errorBag the view's shared $errors
*/
public function checked(mixed $value, bool $default, bool $disabled, bool $alwaysSent, mixed $errorBag, string $bag = 'default'): bool
{
// Bound to a Livewire property: that says, true/false, or for a list of boxes, whether it holds this value.
[$found, $live] = $this->fromLivewire();
if ($found) {
return is_array($live) ? in_array(self::text($value), array_map(self::text(...), $live), true) : (bool) $live;
}
if ($this->key === null || $disabled || !session()->hasOldInput()) {
return $default;
}
$old = old($this->key);
if ($old !== null) {
return in_array(self::text($value), array_map(self::text(...), Arr::wrap($old)), true);
}
// Nothing under this name, though this box always sends something: its form wasn't the one submitted.
if ($alwaysSent) {
return $default;
}
// A form with its own error bag: no errors in that bag means the failed submit was a different form.
if ($bag !== 'default') {
return $errorBag instanceof ViewErrorBag && $errorBag->getBag($bag)->isNotEmpty() ? false : $default;
}
// One form, or forms sharing the default bag: there's no telling them apart, so trust the old input.
return false;
}
/** Values cast to backed enums (Plan::Pro) compare as their backing value. */
private static function text(mixed $value): string
{
return (string) ($value instanceof \BackedEnum ? $value->value : $value);
}
/**
* What the caller passed, minus `class` (that styles the wrapper) and the aria attributes
* this widget manages itself. Goes on the element that is actually submitted.
*/
public function forwarded(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return $attributes->except(['class', 'aria-invalid', 'aria-describedby']);
}
/**
* aria-invalid plus one aria-describedby that joins the error, the hint and the caller's own
* ids. Two separate aria-describedby attributes would make the browser silently drop one.
*/
public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
$describedBy = array_filter([
$this->hasError() ? $this->errorId() : null,
$hasInfo ? $this->infoId() : null,
$attributes->get('aria-describedby'),
]);
return new ComponentAttributeBag([
'aria-invalid' => $this->hasError() ? 'true' : null,
'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),
]);
}
/**
* For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the
* hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.
*/
public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));
}
/**
* The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,
* placeholder…) goes on the visible control, where the browser validates it and screen readers hear it.
*/
public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => !self::isBinding($key)), $hasInfo);
}
private static function isBinding(string $key): bool
{
return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');
}
/**
* forwarded() and aria() together, for widgets whose visible control is also the submitted one.
*/
public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());
}
}