View as Markdown

Input OTP

A group of single-character inputs for one-time passwords and verification codes.

100%
Loading...
<twig:InputOtp inputs="6">
    <twig:InputOtp:Group>
        {% for i in 1..6 %}
            <twig:InputOtp:Slot input="{{ i }}" value="{{ i }}" />
        {% endfor %}
    </twig:InputOtp:Group>
</twig:InputOtp>

Installation

php bin/console ux:install input-otp --kit shadcn

Install the following Composer dependencies:

composer require twig/html-extra:^3.24.0 symfony/ux-icons symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0

Copy the following file(s) into your app:

assets/controllers/input_otp_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['slot'];

    _patternCache = new Map();

    onInput(event) {
        const slot = event.target;

        if (slot.value.length > 1) {
            slot.value = slot.value.slice(-1);
        }
        if (!slot.value) return;

        // Native `pattern` only validates on submit; enforce it here to block disallowed characters as they are typed.
        if (!this._matchesPattern(slot, slot.value)) {
            slot.value = '';
            return;
        }

        this._focusNext(slot);
    }

    onKeyDown(event) {
        const slots = this.slotTargets;
        const index = slots.indexOf(event.target);

        if (index > 0 && (event.key === 'ArrowLeft' || (event.key === 'Backspace' && !event.target.value))) {
            event.preventDefault();
            this._focusSlot(slots[index - 1]);
            return;
        }

        if (event.key === 'ArrowRight' && index < slots.length - 1) {
            event.preventDefault();
            this._focusSlot(slots[index + 1]);
        }
    }

    onPaste(event) {
        const pasted = (event.clipboardData || window.clipboardData).getData('text');
        if (!pasted) return;
        event.preventDefault();

        const slots = this.slotTargets;
        const startIndex = slots.indexOf(event.target);

        let filled = 0;
        for (const char of pasted) {
            const slot = slots[startIndex + filled];
            if (!slot) break;
            if (!this._matchesPattern(slot, char)) continue;
            slot.value = char;
            filled++;
        }
        if (filled === 0) return;

        this._focusSlot(slots[Math.min(startIndex + filled, slots.length - 1)]);
        this.element.dispatchEvent(new Event('input', { bubbles: true }));
    }

    _focusNext(slot) {
        const slots = this.slotTargets;
        const index = slots.indexOf(slot);
        if (index >= 0 && index < slots.length - 1) {
            this._focusSlot(slots[index + 1]);
        }
    }

    _focusSlot(slot) {
        slot.focus();
        slot.select();
    }

    _matchesPattern(slot, value) {
        const pattern = slot.getAttribute('pattern');
        if (!pattern) return true;

        let regex = this._patternCache.get(pattern);
        if (regex === undefined) {
            try {
                regex = new RegExp(`^(?:${pattern})$`);
            } catch {
                regex = null;
            }
            this._patternCache.set(pattern, regex);
        }
        return regex === null || regex.test(value);
    }
}
assets/controllers/input_otp_display_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['output', 'empty', 'filled'];

    update(event) {
        const otp = event.currentTarget;
        const slots = Array.from(otp.querySelectorAll('[data-slot="input-otp-slot"]'));
        const value = slots.map((s) => s.value).join('');

        if (this.hasOutputTarget) {
            this.outputTarget.textContent = value;
        }
        if (this.hasEmptyTarget && this.hasFilledTarget) {
            this.emptyTarget.hidden = value !== '';
            this.filledTarget.hidden = value === '';
        }
    }
}
templates/components/InputOtp.html.twig
{# @prop inputs int|null Total number of slots in this OTP, shared with the `InputOtp:Slot` children to build their accessible "Input X of Y" label. #}
{# @prop name string Base name of the OTP inputs, shared with the `InputOtp:Slot` children to build their default `name`, e.g. `otp` yields `otp[0]`, `otp[1]`, ... #}
{# @block content The OTP slot inputs, typically multiple `InputOtp:Slot` components. #}
{%- props inputs = null, name = 'otp' -%}
{%- do provide('inputOtp.inputs', inputs) -%}
{%- do provide('inputOtp.name', name) -%}
<div
    data-slot="input-otp"
    {{ attributes.defaults({
        class: 'flex items-center gap-2 has-[:disabled]:opacity-50'|tailwind_classes,
        'data-controller': 'input-otp',
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/InputOtp/Group.html.twig
{# @block content The `InputOtp:Slot` components within this group. #}
<div
    data-slot="input-otp-group"
    {{ attributes.defaults({
        class: 'flex items-center'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/InputOtp/Separator.html.twig
<span
    role="separator"
    aria-hidden="true"
    data-slot="input-otp-separator"
    {{ attributes.defaults({
        class: 'text-muted-foreground [&>svg]:size-4'|tailwind_classes,
    }) }}
>
    <twig:ux:icon name="lucide:minus" />
</span>
templates/components/InputOtp/Slot.html.twig
{# @prop input int|null Position of this slot (1-based), combined with the `inputs` and `name` props of the parent `InputOtp` to build the accessible "Input X of Y" label and the default `name`. #}
{# @prop label string|null Accessible label of this slot, overriding the "Input X of Y" one built from `input`. #}
{%- props input = null, label = null -%}
{%- set _inputOtp_name = null -%}
{%- if input is not null -%}
    {%- set _inputOtp_inputs = inject('inputOtp.inputs') -%}
    {%- set label = label ?? ('Input ' ~ input ~ (_inputOtp_inputs is not null ? ' of ' ~ _inputOtp_inputs : '')) -%}
    {%- set _inputOtp_name = inject('inputOtp.name', 'otp') ~ '[' ~ (input - 1) ~ ']' -%}
{%- endif -%}
<input
    data-slot="input-otp-slot"
    data-input-otp-target="slot"
    {% if label %}aria-label="{{ label }}"{% endif %}
    {{ attributes.defaults({
        class: 'relative flex h-10 w-10 items-center justify-center border-y border-e border-input bg-transparent text-center text-sm shadow-xs transition-all outline-none focus-visible:z-10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/60 first:rounded-s-md first:border-s last:rounded-e-md'|tailwind_classes,
        'data-action': 'input->input-otp#onInput keydown->input-otp#onKeyDown paste->input-otp#onPaste',
        type: 'text',
        inputmode: 'numeric',
        maxlength: 1,
        autocomplete: 'one-time-code',
        name: _inputOtp_name,
    }) }}
>

Usage

<twig:InputOtp inputs="6">
    <twig:InputOtp:Slot input="1" />
    <twig:InputOtp:Slot input="2" />
    <twig:InputOtp:Slot input="3" />
    <twig:InputOtp:Slot input="4" />
    <twig:InputOtp:Slot input="5" />
    <twig:InputOtp:Slot input="6" />
</twig:InputOtp>

Examples

Four Digits

Use the inputs prop on InputOtp to control the number of slots; each InputOtp:Slot derives its accessible label and name from it and its own input position.

100%
Loading...
<twig:InputOtp inputs="4">
    <twig:InputOtp:Group>
        {% for i in 1..4 %}
            <twig:InputOtp:Slot input="{{ i }}" pattern="[0-9]" />
        {% endfor %}
    </twig:InputOtp:Group>
</twig:InputOtp>

Separator

Use InputOtp:Separator between InputOtp:Group components to visually split the code.

100%
Loading...
<twig:InputOtp inputs="6">
    <twig:InputOtp:Group>
        <twig:InputOtp:Slot input="1" />
        <twig:InputOtp:Slot input="2" />
    </twig:InputOtp:Group>
    <twig:InputOtp:Separator />
    <twig:InputOtp:Group>
        <twig:InputOtp:Slot input="3" />
        <twig:InputOtp:Slot input="4" />
    </twig:InputOtp:Group>
    <twig:InputOtp:Separator />
    <twig:InputOtp:Group>
        <twig:InputOtp:Slot input="5" />
        <twig:InputOtp:Slot input="6" />
    </twig:InputOtp:Group>
</twig:InputOtp>

Pattern

Use the pattern attribute on each InputOtp:Slot to restrict the accepted characters.

100%
Loading...
<div class="flex flex-col gap-2 w-fit">
    <twig:Label for="digits-only">Digits Only</twig:Label>
    <twig:InputOtp inputs="6" id="digits-only">
        <twig:InputOtp:Group>
            {% for i in 1..6 %}
                <twig:InputOtp:Slot input="{{ i }}" pattern="[0-9]" />
            {% endfor %}
        </twig:InputOtp:Group>
    </twig:InputOtp>
</div>

Alphanumeric

Combine inputmode="text" with a pattern to accept letters and digits.

100%
Loading...
<twig:InputOtp inputs="6">
    <twig:InputOtp:Group>
        <twig:InputOtp:Slot input="1" inputmode="text" pattern="[A-Za-z0-9]" />
        <twig:InputOtp:Slot input="2" inputmode="text" pattern="[A-Za-z0-9]" />
        <twig:InputOtp:Slot input="3" inputmode="text" pattern="[A-Za-z0-9]" />
    </twig:InputOtp:Group>
    <twig:InputOtp:Separator />
    <twig:InputOtp:Group>
        <twig:InputOtp:Slot input="4" inputmode="text" pattern="[A-Za-z0-9]" />
        <twig:InputOtp:Slot input="5" inputmode="text" pattern="[A-Za-z0-9]" />
        <twig:InputOtp:Slot input="6" inputmode="text" pattern="[A-Za-z0-9]" />
    </twig:InputOtp:Group>
</twig:InputOtp>

Controlled

Listen to the input event to react to the value as the user types, here with a small display controller.

100%
Loading...
<div class="space-y-2" data-controller="input-otp-display">
    <twig:InputOtp inputs="6" data-action="input->input-otp-display#update">
        <twig:InputOtp:Group>
            {% for i in 1..6 %}
                <twig:InputOtp:Slot input="{{ i }}" />
            {% endfor %}
        </twig:InputOtp:Group>
    </twig:InputOtp>
    <div class="text-center text-sm">
        <span data-input-otp-display-target="empty">Enter your one-time password.</span>
        <span data-input-otp-display-target="filled" hidden>You entered: <span data-input-otp-display-target="output"></span></span>
    </div>
</div>

Disabled

Use the disabled attribute on the slots to disable the OTP input.

100%
Loading...
<twig:InputOtp inputs="6">
    <twig:InputOtp:Group>
        <twig:InputOtp:Slot input="1" value="1" disabled />
        <twig:InputOtp:Slot input="2" value="2" disabled />
        <twig:InputOtp:Slot input="3" value="3" disabled />
    </twig:InputOtp:Group>
    <twig:InputOtp:Separator />
    <twig:InputOtp:Group>
        <twig:InputOtp:Slot input="4" value="4" disabled />
        <twig:InputOtp:Slot input="5" value="5" disabled />
        <twig:InputOtp:Slot input="6" value="6" disabled />
    </twig:InputOtp:Group>
</twig:InputOtp>

Invalid

Use aria-invalid="true" on the slots to mark the code as invalid.

100%
Loading...
<twig:InputOtp inputs="6">
    <twig:InputOtp:Group>
        <twig:InputOtp:Slot input="1" value="0" aria-invalid="true" />
        <twig:InputOtp:Slot input="2" value="0" aria-invalid="true" />
    </twig:InputOtp:Group>
    <twig:InputOtp:Separator />
    <twig:InputOtp:Group>
        <twig:InputOtp:Slot input="3" value="0" aria-invalid="true" />
        <twig:InputOtp:Slot input="4" value="0" aria-invalid="true" />
    </twig:InputOtp:Group>
    <twig:InputOtp:Separator />
    <twig:InputOtp:Group>
        <twig:InputOtp:Slot input="5" value="0" aria-invalid="true" />
        <twig:InputOtp:Slot input="6" value="0" aria-invalid="true" />
    </twig:InputOtp:Group>
</twig:InputOtp>

Form

A complete verification form combining InputOtp with Card, Label and Button.

100%
Loading...
<twig:Card class="">
    <twig:Card:Header>
        <twig:Card:Title>Verify your login</twig:Card:Title>
        <twig:Card:Description>
            Enter the verification code we sent to your email address: <span class="">m@example.com</span>.
        </twig:Card:Description>
    </twig:Card:Header>
    <twig:Card:Content>
        <div class="">
            <div class="">
                <twig:Label for="otp-verification">Verification code</twig:Label>
                <twig:Button variant="outline" size="sm" class="">
                    <twig:ux:icon name="lucide:refresh-cw" class="" />
                    Resend Code
                </twig:Button>
            </div>
            <twig:InputOtp inputs="6" id="otp-verification">
                <twig:InputOtp:Group class="">
                    <twig:InputOtp:Slot input="1" required />
                    <twig:InputOtp:Slot input="2" required />
                    <twig:InputOtp:Slot input="3" required />
                </twig:InputOtp:Group>
                <twig:InputOtp:Separator class="" />
                <twig:InputOtp:Group class="">
                    <twig:InputOtp:Slot input="4" required />
                    <twig:InputOtp:Slot input="5" required />
                    <twig:InputOtp:Slot input="6" required />
                </twig:InputOtp:Group>
            </twig:InputOtp>
            <p class="">
                <a href="#" class="">I no longer have access to this email address.</a>
            </p>
        </div>
    </twig:Card:Content>
    <twig:Card:Footer class="">
        <twig:Button type="submit" class="">Verify</twig:Button>
        <div class="">
            Having trouble signing in?
            <a href="#" class="">Contact support</a>
        </div>
    </twig:Card:Footer>
</twig:Card>

RTL

Use the name prop on InputOtp to change the base name of the inputs; here with right-to-left layouts in Arabic and Hebrew.

100%
Loading...
<div class="flex flex-col items-center gap-4">
    {# Arabic #}
    <div dir="rtl" class="flex flex-col gap-2 w-fit">
        <twig:Label for="ar-otp">رمز التحقق</twig:Label>
        <twig:InputOtp inputs="6" name="ar-otp" id="ar-otp">
            <twig:InputOtp:Group>
                {% for i in 1..6 %}
                    <twig:InputOtp:Slot input="{{ i }}" />
                {% endfor %}
            </twig:InputOtp:Group>
        </twig:InputOtp>
    </div>

    {# Hebrew #}
    <div dir="rtl" class="flex flex-col gap-2 w-fit">
        <twig:Label for="he-otp">קוד אימות</twig:Label>
        <twig:InputOtp inputs="6" name="he-otp" id="he-otp">
            <twig:InputOtp:Group>
                {% for i in 1..6 %}
                    <twig:InputOtp:Slot input="{{ i }}" />
                {% endfor %}
            </twig:InputOtp:Group>
        </twig:InputOtp>
    </div>
</div>

API Reference

<twig:InputOtp>

Prop Type Default
inputs 
int|null null
name 
string 'otp'
Block Description
content The OTP slot inputs, typically multiple InputOtp:Slot components.

<twig:InputOtp:Group>

Block Description
content The InputOtp:Slot components within this group.

<twig:InputOtp:Slot>

Prop Type Default
input 
int|null null
label 
string|null null