Holidays Deal Full Access for 50% OFF. Use code lex50 at checkout.

You'll get every theme available plus future additions. That's 43 themes total. Unlimited projects. Lifetime updates. One payment.

How to build an OTP input group with Tailwind CSS and Alpine.js

Create a polished 6-digit OTP input that uses Alpine.js for caret simulation, validation, and focus styling—all wrapped in accessible Tailwind CSS markup.

Published on November 26, 2025 by Michael Andreuzza

One-time password fields live everywhere from banking to two-factor logins, yet the native <input type="number"> experience can feel clunky. This component overlays an invisible text field on top of six visual slots, letting Alpine.js handle digit parsing and focus states.

Why this pattern feels natural

  • Users type continuously, but the digits animate into separate boxes.
  • A single hidden input keeps the form simple—no juggling six inputs.
  • The focus ring tracks the next slot, so the user always knows where they are.
  • It supports numeric keyboards via inputmode="numeric" and autocomplete="one-time-code" for SMS autofill.

1. Alpine state and derived digits

Alpine stores the raw value and exposes a computed digits array that always renders six slots.

<div
  x-data="{
    value: '',
    isFocused: false,
    get digits() {
      return this.value.padEnd(6).split('').slice(0, 6);
    },
    onInput(e) {
      this.value = e.target.value.replace(/\D/g, '').slice(0, 6);
    }
  }"
>
  <!-- content -->
</div>
  • padEnd(6) ensures empty slots render as blank strings.
  • replace(/\D/g, '') removes non-numeric characters, keeping the OTP clean.

2. Labeling and helper text

Wrap the label/helper in a flex row so the instruction sits to the right.

<div class="flex justify-between items-baseline mb-1">
  <label for="otp" class="font-medium text-zinc-500 text-sm">Verification Code</label>
  <span class="text-zinc-400 text-sm">Enter the 6-digit code</span>
</div>
  • Always tie the label to the input via for/id for assistive tech.
  • Keep helper text subtle with text-zinc-400 so it doesn’t overpower the label.

3. Slot rendering with x-for

Each visible box is a flex square. Alpine loops through the digits array and highlights the box that matches the current caret index.

<template x-for="(digit, i) in digits" :key="i">
  <div
    :class="[
      'size-10 rounded-md text-center font-medium text-lg flex items-center justify-center transition-all',
      'border bg-white text-zinc-500',
      isFocused && value.length === i
        ? 'border-blue-500 ring-2 ring-blue-500'
        : 'border-zinc-300'
    ]"
  >
    <span x-text="digit"></span>
  </div>
</template>
  • The conditional ring only shows when the hidden input is focused and the caret is on that slot.
  • transition-all gives a subtle snap when focus changes.

4. Hidden input for actual data entry

Place a transparent input above the slots so it receives all keystrokes.

<input
  id="otp"
  name="otp"
  type="text"
  inputmode="numeric"
  autocomplete="one-time-code"
  maxlength="6"
  x-model="value"
  @input="onInput"
  @focus="isFocused = true"
  @blur="isFocused = false"
  class="absolute inset-0 z-10 w-full h-full opacity-0 cursor-text"
  aria-label="Verification Code"
/>
  • opacity-0 keeps it clickable; avoid display: none or visibility: hidden so it remains focusable.
  • maxlength="6" stops autoresolved autofill from overflowing the buffer.

5. Copy-and-paste snippet

Drop the snippet anywhere inside an Alpine-enabled page.

<div class="flex justify-center">
  <div
    class="w-full"
    x-data="{
    value: '',
    isFocused: false,
    get digits() {
      return this.value.padEnd(6).split('').slice(0, 6);
    },
    onInput(e) {
      this.value = e.target.value.replace(/\D/g, '').slice(0, 6);
    }
  }"
  >
    <div class="flex justify-between items-baseline mb-1">
      <label for="otp" class="font-medium text-zinc-500 text-sm"
        >Verification Code</label
      ><span class="text-zinc-400 text-sm">Enter the 6-digit code</span>
    </div>
    <div class="relative flex items-center gap-2">
      <template x-for="(digit, i) in digits" x-bind:key="i"
        ><div
          x-bind:class="[
          'size-10 rounded-md text-center font-medium text-lg flex items-center justify-center transition-all',
          'border bg-white text-zinc-500  ',
          isFocused && value.length === i ? 'border-blue-500 ring-2 ring-blue-500' : 'border-zinc-300 '
        ]"
        >
          <span x-text="digit"></span></div></template
      ><input
        id="otp"
        name="otp"
        type="text"
        inputmode="numeric"
        autocomplete="one-time-code"
        maxlength="6"
        x-model="value"
        x-on:input="onInput"
        x-on:focus="isFocused = true"
        x-on:blur="isFocused = false"
        class="absolute inset-0 z-10 w-full h-full opacity-0 cursor-text"
        aria-label="Verification Code"
      />
    </div>
  </div>
</div>

Finishing touches

  • Replace the placeholder label text if you support multiple OTP types (SMS, email, authenticator).
  • Add @paste logic if you want to handle pasted codes; you can reuse onInput to sanitize the value.
  • For server-side validation, listen to Alpine’s x-on:change or use Astro/HTML form submission—the hidden input already contains the full code.

/Michael Andreuzza