One-time password inputs are everywhere: two-factor authentication screens, email verification flows, checkout confirmations. The UX requirement is always the same — six separate boxes, each holding one digit, with the cursor jumping automatically to the next box as you type. Getting that feeling exactly right takes a little coordination between the DOM and your component state. Alpine.js is a near-perfect fit for this: it lives in the HTML, keeps state close to the markup, and handles the event wiring without a build step.
This guide builds a complete, production-ready OTP input group from scratch. By the end you will have: auto-advance on input, backspace-to-previous navigation, paste support for the whole code at once, inputmode="numeric" for mobile number keyboards, and autocomplete="one-time-code" for SMS autofill. The component is self-contained — no external dependencies beyond Alpine.js and Tailwind CSS v4.
How the component works
The approach is straightforward: render six individual <input> elements side-by-side. Alpine holds the six digit values in a reactive array and wires up three event handlers on each input: @input (typed a digit → advance), @keydown.backspace (clearing a box → step back), and @paste (pasted the full code → distribute across boxes). Each input gets an x-ref so we can call .focus() on it programmatically.
The hidden <input type="hidden"> at the bottom lets a standard <form> submit the assembled six-digit string — no JavaScript required on the server side.
Setting up Tailwind CSS v4
Tailwind v4 moved configuration out of tailwind.config.js and into your CSS file using the @theme directive. If you are starting fresh, your stylesheet needs just two things:
/* src/styles/global.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(0.55 0.22 265);
--radius-input: 0.5rem;
}
Those two custom tokens — --color-brand and --radius-input — automatically generate utility classes like bg-brand, ring-brand, and rounded-input, which you will see used in the component below. Everything else in the component uses Tailwind's built-in utilities unchanged.
Loading Alpine.js
Alpine.js loads from a CDN or via npm. For a quick prototype or a standalone HTML file the CDN is simplest:
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
In an Astro project, install it with npm install alpinejs, then register it in your layout:
// src/scripts/alpine.js
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
Then import that script in your Astro layout with is:inline or via a <script> tag at the bottom of <body>.
The component markup
Here is the full OTP component. Everything lives in one HTML block — the Alpine data, the event handlers, and the Tailwind styling:
<div
x-data="{
digits: ['', '', '', '', '', ''],
get otp() { return this.digits.join('') },
onInput(index, event) {
const raw = event.target.value;
const digit = raw.replace(/\D/g, '').slice(-1);
this.digits[index] = digit;
event.target.value = digit;
if (digit && index < 5) {
this.$nextTick(() => this.$refs['input' + (index + 1)].focus());
}
},
onBackspace(index, event) {
if (this.digits[index] === '' && index > 0) {
this.$nextTick(() => {
this.$refs['input' + (index - 1)].focus();
this.$refs['input' + (index - 1)].select();
});
} else {
this.digits[index] = '';
}
},
onPaste(event) {
event.preventDefault();
const pasted = (event.clipboardData || window.clipboardData)
.getData('text')
.replace(/\D/g, '')
.slice(0, 6);
pasted.split('').forEach((char, i) => {
this.digits[i] = char;
});
const focusIndex = Math.min(pasted.length, 5);
this.$nextTick(() => this.$refs['input' + focusIndex].focus());
}
}"
class="flex flex-col items-center gap-6"
>
<!-- Label -->
<label class="text-sm font-medium text-gray-700">
Enter your 6-digit code
</label>
<!-- Six input boxes -->
<div class="flex gap-3">
<template x-for="(digit, index) in digits" :key="index">
<input
:x-ref="'input' + index"
type="text"
inputmode="numeric"
maxlength="1"
:value="digit"
@input="onInput(index, $event)"
@keydown.backspace="onBackspace(index, $event)"
@paste="onPaste($event)"
@focus="$event.target.select()"
:autocomplete="index === 0 ? 'one-time-code' : 'off'"
class="w-12 h-14 rounded-input border border-gray-300 bg-white text-center text-xl font-semibold text-gray-900 shadow-sm transition focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand"
aria-label="'Digit ' + (index + 1) + ' of 6'"
/>
</template>
</div>
<!-- Hidden field for form submission -->
<input type="hidden" name="otp" :value="otp" />
<!-- Submit button -->
<button
type="submit"
:disabled="otp.length < 6"
class="rounded-input bg-brand px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>
Verify code
</button>
</div>
Breaking down the key pieces
The digits array and the otp getter
Alpine's reactive digits array holds each box's value as a string ('' when empty, '0'–'9' when filled). The computed getter otp joins them without a separator, giving you the assembled string for form submission and for the :disabled check on the button. Because it is a getter on the plain object Alpine wraps, it recomputes automatically whenever digits changes.
onInput — typing a digit and advancing
The onInput handler strips any non-numeric character with replace(/\D/g, '') and takes only the last character typed (covering the edge case where a browser pre-populates the value before Alpine clears it). It writes the sanitized digit back to both digits[index] and event.target.value to keep them in sync, then focuses the next input using this.$refs['input' + (index + 1)] inside $nextTick so Alpine has finished its DOM update first.
onBackspace — stepping backwards
There are two cases. If the current box already has a digit, pressing Backspace clears it — the browser's default behaviour does this naturally. If the box is already empty, the user wants to go back one step, so the handler focuses and selects the previous input, ready to be overwritten. The select() call means a follow-up keystroke replaces the digit rather than appending to it.
onPaste — distributing the full code
When a user copies a code from an SMS app and pastes it anywhere inside the group, the paste event fires. The handler reads the clipboard text, strips non-numeric characters, and distributes the digits across the array. It then focuses whichever box comes after the last filled digit. event.preventDefault() stops the browser from also inserting raw text into the active input.
SMS autofill via autocomplete="one-time-code"
Setting autocomplete="one-time-code" on the first input is the signal that tells iOS and Android to offer the incoming SMS code as a keyboard suggestion. When the user taps "123456" on the suggestion bar, the browser fires a paste-like event — the onPaste handler catches it automatically, so no extra work is needed.
x-ref inside x-for
There is a subtlety with x-ref inside an x-for loop. The standard x-ref="myref" attribute is static; inside a loop you need a dynamic binding: :x-ref="'input' + index". Alpine evaluates the colon-prefixed attribute expression and registers the resulting string as the ref name, so this.$refs.input0 through this.$refs.input5 all resolve correctly.
Accessibility considerations
Each input carries an aria-label like "Digit 1 of 6" so screen readers announce position. The inputmode="numeric" attribute summons the number keyboard on mobile without restricting the input type to number, which would add spinner arrows and prevent leading zeros. The submit button's :disabled binding gives users a clear visual signal that the form is not ready, and the CSS disabled:cursor-not-allowed reinforces that.
If you need the full ARIA pattern for grouped code input (role, aria-describedby, live region for errors), wrap the six inputs in a <fieldset> with a <legend> — screen readers will announce the legend before each individual input's label.
Styling variations
The Tailwind classes above give you a clean, minimal look. A few common variations:
- Filled boxes with no border: replace
border border-gray-300 bg-whitewithbg-gray-100 border-transparentand adjust the focus ring to use a bottom-border-only style. - Wider gap on larger screens: use
gap-3 sm:gap-4on the flex container so boxes breathe on desktop but stay compact on mobile. - Error state: add a reactive
errorboolean to your Alpine data and conditionally applyborder-red-500 ring-red-500to each input with:class="{ 'border-red-500 ring-red-500 ring-2': error }". - Larger or smaller boxes: swap
w-12 h-14forw-10 h-12(compact) orw-14 h-16(prominent). The text size, center alignment, and font weight stay the same.
Wiring it into a form
Wrap the component in a <form> with your preferred action and method. The hidden <input name="otp"> carries the assembled value on submit:
<form method="POST" action="/verify">
<!-- the OTP component x-data div goes here -->
</form>
For a client-side SPA or an Astro API route you would intercept the submit event instead:
<form @submit.prevent="submitOtp(otp)">
<!-- component -->
</form>
Add a submitOtp method to the x-data object (or define it as a separate Alpine component with Alpine.data('otpForm', () => ({...}))) to run your fetch call, show a loading state, or redirect on success.
Conclusion
Alpine.js and Tailwind CSS v4 together keep this component compact and readable. There is no virtual DOM overhead, no separate state management library, and no build step if you are using CDN links. The critical pieces — digit sanitization, $nextTick-guarded focus moves, dynamic x-ref bindings, and paste distribution — each solve a real edge case that would otherwise frustrate users. Drop this into any HTML page or Astro component and you have a production-ready OTP input in under 80 lines.