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 · 3 min read

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