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 AndreuzzaOne-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"andautocomplete="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/idfor assistive tech. - Keep helper text subtle with
text-zinc-400so 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-allgives 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-0keeps it clickable; avoiddisplay: noneorvisibility: hiddenso 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
@pastelogic if you want to handle pasted codes; you can reuseonInputto sanitize the value. - For server-side validation, listen to Alpine’s
x-on:changeor use Astro/HTML form submission—the hidden input already contains the full code.
/Michael Andreuzza