Two-Factor Authentication Components
Secure verification code input components for two-factor authentication. Perfect for OTP verification, SMS codes, and security confirmation.
Installation
1. Stimulus Controller Setup
Start by adding the following controller to your project:
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["digit", "form", "submitButton"];
static values = {
autoSubmit: { type: Boolean, default: false }, // Whether to automatically submit the form
autofocus: { type: Boolean, default: true }, // Whether to autofocus the first input
numericOnly: { type: Boolean, default: true }, // Whether to allow only digits (0-9)
};
connect() {
const inputs = this._getInputTargets();
// Set autofocus on the first input if autofocus is enabled
if (this.autofocusValue && inputs[0]) {
inputs[0].focus();
}
// Keep a stable function reference for proper listener cleanup.
this.boundHandleFocus = this.handleFocus.bind(this);
// Add focus event listeners to all input targets.
inputs.forEach((input) => {
input.addEventListener("focus", this.boundHandleFocus);
});
}
disconnect() {
// Clean up event listeners
this._getInputTargets().forEach((input) => {
input.removeEventListener("focus", this.boundHandleFocus);
});
}
handleFocus(event) {
const input = event.target;
// Move cursor to end of input value
setTimeout(() => {
input.setSelectionRange(input.value.length, input.value.length);
}, 0);
}
isNumber(value) {
return /^[0-9]$/.test(value);
}
handleInput(event) {
const currentInput = event.target;
const nextInput = this._getNextInput(currentInput);
const normalizedValue = this._normalizeSingleValue(currentInput.value);
currentInput.value = normalizedValue;
if (this._isValidCharacter(normalizedValue)) {
if (nextInput) {
nextInput.focus();
} else {
// Last input filled
this.handleSubmit();
}
}
}
handleKeydown(event) {
const currentInput = event.target;
// Handle backspace navigation
if (event.key === "Backspace" && currentInput.value === "") {
const prevInput = this._getPreviousInput(currentInput);
if (prevInput) {
prevInput.focus();
}
}
// Handle arrow key navigation
if (event.key === "ArrowLeft") {
event.preventDefault();
const prevInput = this._getPreviousInput(currentInput);
if (prevInput) {
prevInput.focus();
}
}
if (event.key === "ArrowRight") {
event.preventDefault();
const nextInput = this._getNextInput(currentInput);
if (nextInput) {
nextInput.focus();
}
}
}
handlePaste(event) {
event.preventDefault();
const paste = this._normalizePasteValue((event.clipboardData || window.clipboardData).getData("text"));
const inputs = this._getInputTargets();
if (!paste.length || !inputs.length) {
return;
}
const characters = paste.slice(0, inputs.length).split("");
characters.forEach((character, index) => {
inputs[index].value = character;
});
if (characters.length === inputs.length) {
this.handleSubmit();
return;
}
const nextInput = inputs[characters.length];
if (nextInput) {
nextInput.focus();
}
}
handleSubmit(event) {
if (event) {
// If triggered by form submit event
event.preventDefault();
}
if (this.hasSubmitButtonTarget && this._isComplete()) {
this.submitButtonTarget.focus();
}
// Submit form if autoSubmit is true
if (this.autoSubmitValue) {
if (this.hasFormTarget) {
this.formTarget.submit();
}
}
}
_getInputTargets() {
if (this.hasDigitTarget) {
return this.digitTargets;
}
// Backward-compatible fallback for older markup using num1..numN targets.
return Array.from(this.element.querySelectorAll('[data-two-factor-target^="num"]'));
}
_getNextInput(currentInput) {
const inputs = this._getInputTargets();
const currentIndex = inputs.indexOf(currentInput);
if (currentIndex !== -1 && currentIndex < inputs.length - 1) {
return inputs[currentIndex + 1];
}
return null;
}
_getPreviousInput(currentInput) {
const inputs = this._getInputTargets();
const currentIndex = inputs.indexOf(currentInput);
if (currentIndex > 0) {
return inputs[currentIndex - 1];
}
return null;
}
_isComplete() {
const inputs = this._getInputTargets();
return inputs.length > 0 && inputs.every((input) => this._isValidCharacter(input.value));
}
_normalizeSingleValue(value) {
const withoutWhitespace = value.replace(/\s/g, "");
if (this.numericOnlyValue) {
return withoutWhitespace.replace(/\D/g, "").slice(-1);
}
return withoutWhitespace.slice(-1);
}
_normalizePasteValue(value) {
const withoutWhitespace = value.replace(/\s/g, "");
if (this.numericOnlyValue) {
return withoutWhitespace.replace(/\D/g, "");
}
return withoutWhitespace;
}
_isValidCharacter(value) {
if (this.numericOnlyValue) {
return this.isNumber(value);
}
return value.length === 1;
}
}
Examples
Basic Two-Factor Authentication
A complete two-factor authentication form with 6-digit verification code input.
<div class="mx-auto w-full max-w-xl py-6" data-controller="two-factor">
<div class="rounded-2xl border bg-white dark:bg-neutral-900 border-neutral-200 text-center dark:border-neutral-700">
<div class="p-5 sm:p-8 md:p-12">
<svg xmlns="http://www.w3.org/2000/svg" class="size-6 mx-auto" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><line x1="9" y1="11" x2="9" y2="12"></line><path d="M6.25,7.628v-3.128c0-1.519,1.231-2.75,2.75-2.75h0c1.519,0,2.75,1.231,2.75,2.75v3.129"></path><circle cx="9" cy="11.5" r="4.75"></circle></g></svg>
<h2 class="my-2 text-2xl font-bold">Two-Factor Authentication</h2>
<h3 class="mb-8 text-sm text-neutral-600 dark:text-neutral-400"3
Confirm your account by entering the verification code sent to
your phone number ending in **432.
</h3>
<form
data-two-factor-target="form"
data-action="submit->two-factor#handleSubmit"
class="space-y-6">
<div class="inline-flex items-center gap-1.5">
<input
data-two-factor-target="digit"
data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste"
type="text"
inputmode="numeric"
id="num1"
name="num1"
maxlength="1"
autocomplete="one-time-code"
required
class="block w-8 rounded-lg border border-neutral-200 dark:border-neutral-600 px-2 py-1 text-center text-base placeholder-neutral-500 focus:outline-neutral-800 focus:border-neutral-800 dark:focus:outline-neutral-50 dark:focus:border-neutral-50 dark:bg-neutral-800 dark:placeholder-neutral-400">
<input
data-two-factor-target="digit"
data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste"
type="text"
inputmode="numeric"
id="num2"
name="num2"
maxlength="1"
autocomplete="off"
required
class="block w-8 rounded-lg border border-neutral-200 dark:border-neutral-600 px-2 py-1 text-center text-base placeholder-neutral-500 focus:outline-neutral-800 focus:border-neutral-800 dark:focus:outline-neutral-50 dark:focus:border-neutral-50 dark:bg-neutral-800 dark:placeholder-neutral-400">
<input
data-two-factor-target="digit"
data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste"
type="text"
inputmode="numeric"
id="num3"
name="num3"
maxlength="1"
autocomplete="off"
required
class="block w-8 rounded-lg border border-neutral-200 dark:border-neutral-600 px-2 py-1 text-center text-base placeholder-neutral-500 focus:outline-neutral-800 focus:border-neutral-800 dark:focus:outline-neutral-50 dark:focus:border-neutral-50 dark:bg-neutral-800 dark:placeholder-neutral-400">
<span class="text-sm text-neutral-400 dark:text-neutral-600">-</span>
<input
data-two-factor-target="digit"
data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste"
type="text"
inputmode="numeric"
id="num4"
name="num4"
maxlength="1"
autocomplete="off"
required
class="block w-8 rounded-lg border border-neutral-200 dark:border-neutral-600 px-2 py-1 text-center text-base placeholder-neutral-500 focus:outline-neutral-800 focus:border-neutral-800 dark:focus:outline-neutral-50 dark:focus:border-neutral-50 dark:bg-neutral-800 dark:placeholder-neutral-400">
<input
data-two-factor-target="digit"
data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste"
type="text"
inputmode="numeric"
id="num5"
name="num5"
maxlength="1"
autocomplete="off"
required
class="block w-8 rounded-lg border border-neutral-200 dark:border-neutral-600 px-2 py-1 text-center text-base placeholder-neutral-500 focus:outline-neutral-800 focus:border-neutral-800 dark:focus:outline-neutral-50 dark:focus:border-neutral-50 dark:bg-neutral-800 dark:placeholder-neutral-400">
<input
data-two-factor-target="digit"
data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste"
type="text"
inputmode="numeric"
id="num6"
name="num6"
maxlength="1"
autocomplete="off"
required
class="block w-8 rounded-lg border border-neutral-200 dark:border-neutral-600 px-2 py-1 text-center text-base placeholder-neutral-500 focus:outline-neutral-800 focus:border-neutral-800 dark:focus:outline-neutral-50 dark:focus:border-neutral-50 dark:bg-neutral-800 dark:placeholder-neutral-400">
</div>
<div class="flex justify-center">
<button
data-two-factor-target="submitButton"
type="submit"
class="min-w-32 flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">
<span>Verify code</span>
</button>
</div>
</form>
<div class="mt-5 text-sm text-neutral-500 dark:text-neutral-400">
Haven't received it?
<a href="#" class="font-medium text-neutral-700 underline decoration-neutral-500/50 underline-offset-2 hover:text-neutral-900 dark:text-neutral-300 dark:decoration-neutral-400/50 dark:hover:text-neutral-100">
Get a new code
</a>
</div>
</div>
</div>
</div>
Configuration
The two-factor authentication component is powered by a Stimulus controller that provides automatic focus management, configurable input validation (numeric-only or any character), paste support, and auto-submit functionality.
Controller Setup
Basic two-factor authentication structure with required data attributes:
<div data-controller="two-factor" data-two-factor-numeric-only-value="true">
<form data-two-factor-target="form" data-action="submit->two-factor#handleSubmit">
<input data-two-factor-target="digit" data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code">
<input data-two-factor-target="digit" data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste" type="text" inputmode="numeric" maxlength="1" autocomplete="off">
<input data-two-factor-target="digit" data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste" type="text" inputmode="numeric" maxlength="1" autocomplete="off">
<input data-two-factor-target="digit" data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste" type="text" inputmode="numeric" maxlength="1" autocomplete="off">
<input data-two-factor-target="digit" data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste" type="text" inputmode="numeric" maxlength="1" autocomplete="off">
<input data-two-factor-target="digit" data-action="input->two-factor#handleInput keydown->two-factor#handleKeydown paste->two-factor#handlePaste" type="text" inputmode="numeric" maxlength="1" autocomplete="off">
<button data-two-factor-target="submitButton" type="submit">Verify</button>
</form>
</div>
Configuration Values
Targets
Actions
Key Features
- Automatic Navigation: Moves focus to the next input when a valid character is entered
- Backspace Navigation: Moves focus to the previous input when backspace is pressed on an empty field
- Arrow Key Navigation: Use left/right arrow keys to navigate between input fields
- Paste Support: Automatically fills all fields based on the rendered input length
-
Input Validation: Numeric-only by default, with optional alphanumeric/symbol support via
data-two-factor-numeric-only-value="false" - Auto Submit: Optional automatic form submission when all input fields are filled
- Focus Management: Automatic focusing of first input and submit button
Accessibility Features
-
Mobile Optimized: Uses
inputmode="numeric"for OTP/PIN flows and switches toinputmode="text"whennumericOnlyis disabled -
Autocomplete Support: First input uses
autocomplete="one-time-code"for better UX - Screen Reader Friendly: Proper form labels and input attributes for accessibility
- Keyboard Navigation: Full keyboard support with backspace and arrow key navigation
Usage Notes
-
Each input should have
maxlength="1"to limit to a single character -
Use
type="text"withinputmode="numeric"for numeric OTPs, ordata-two-factor-numeric-only-value="false"withinputmode="text"for alphanumeric codes -
The first input should have
autocomplete="one-time-code", others should useautocomplete="off" -
Use the same input actions on every digit field:
input,keydown, andpaste