Input OTP

A one-time code field with a box per character.

Installation

uvx django_shadcn@latest add input_otp

Usage

<c-input-otp maxlength="4" name="pin">
    <c-input-otp.group>
        <c-input-otp.slot index="0" />
        <c-input-otp.slot index="1" />
        <c-input-otp.slot index="2" />
        <c-input-otp.slot index="3" />
    </c-input-otp.group>
</c-input-otp>

One real input sits transparent over the boxes and holds the whole code, so pasting works, the browser can autofill it from an SMS, and phones show the number pad. The boxes below only draw what that input contains. name reaches the input, so the field submits like any other.

Each slot carries its own index, the same way it does upstream.

Examples

Split into groups

<c-input-otp maxlength="6" name="code">
    <c-input-otp.group>
        <c-input-otp.slot index="0" />
        <c-input-otp.slot index="1" />
        <c-input-otp.slot index="2" />
    </c-input-otp.group>
    <c-input-otp.separator />
    <c-input-otp.group>
        <c-input-otp.slot index="3" />
        <c-input-otp.slot index="4" />
        <c-input-otp.slot index="5" />
    </c-input-otp.group>
</c-input-otp>

Disabled

<c-input-otp maxlength="4" disabled>
    ...
</c-input-otp>

In a Django form

<c-field>
    <c-field.label for="{{ form.code.id_for_label }}">Verification code</c-field.label>
    <c-input-otp maxlength="6" name="{{ form.code.html_name }}" id="{{ form.code.id_for_label }}">
        <c-input-otp.group>
            <c-input-otp.slot index="0" />
            <c-input-otp.slot index="1" />
            <c-input-otp.slot index="2" />
            <c-input-otp.slot index="3" />
            <c-input-otp.slot index="4" />
            <c-input-otp.slot index="5" />
        </c-input-otp.group>
    </c-input-otp>
    <c-field.error :errors="form.code.errors" />
</c-field>

Notes

The blinking caret sits on the box the next character will land in, which is the slot at the current length. It is drawn, not real — the actual caret belongs to the transparent input and is hidden.

Upstream restricts the accepted characters through a pattern prop. Here the field takes whatever the input takes; inputmode="numeric" asks phones for the number pad but does not enforce digits, so validate on the server as you would with any other field.