Skip to content
Brok UI

Loading…

No results

Input OTP

Open source

A segmented input for one-time passcodes with paste support.

Version
v1.3.2
Stability
stable
License
MIT
Related
Input
Masked Input
Phone Input

Preview

previews.components.input-otp.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/input-otp.blade.php
  • js resources/js/ui/input-otp.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

input-otp.md
# 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.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

input-otp input-otp-separator input-otp-slot

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

Specialized input

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
root input-otp-slot
Theming hooks
input-otp input-otp-separator input-otp-slot

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/input-otp.blade.php Blade
{{-- 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>
resources/js/ui/input-otp.js JS
/**
 * 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