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 = ["num1", "num2", "num3", "num4", "num5", "num6", "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
  };

  connect() {
    // Set autofocus on the first input if it's a target and autofocus is enabled
    if (this.autofocusValue && this.hasNum1Target) {
      this.num1Target.focus();
    }

    // Add focus event listeners to all input targets
    this._getInputTargets().forEach((input) => {
      input.addEventListener("focus", this.handleFocus.bind(this));
    });
  }

  disconnect() {
    // Clean up event listeners
    this._getInputTargets().forEach((input) => {
      input.removeEventListener("focus", this.handleFocus.bind(this));
    });
  }

  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);

    if (this.isNumber(currentInput.value)) {
      if (nextInput) {
        nextInput.focus();
      } else {
        // Last input filled
        this.handleSubmit();
      }
    } else {
      currentInput.value = "";
    }
  }

  handleKeydown(event) {
    const currentInput = event.target;
    if (event.key === "Backspace" && currentInput.value === "") {
      const prevInput = this._getPreviousInput(currentInput);
      if (prevInput) {
        prevInput.focus();
      }
    }
  }

  handlePaste(event) {
    event.preventDefault();
    const paste = (event.clipboardData || window.clipboardData).getData("text").trim();

    if (paste.length === 6 && /^[0-9]+$/.test(paste)) {
      this.num1Target.value = paste.charAt(0);
      this.num2Target.value = paste.charAt(1);
      this.num3Target.value = paste.charAt(2);
      this.num4Target.value = paste.charAt(3);
      this.num5Target.value = paste.charAt(4);
      this.num6Target.value = paste.charAt(5);
      this.handleSubmit();
      this.num6Target.focus(); // Focus last input after paste, then handleSubmit will move to button
    }
  }

  handleSubmit(event) {
    if (event) {
      // If triggered by form submit event
      event.preventDefault();
    }
    if (this.hasSubmitButtonTarget) {
      this.submitButtonTarget.focus();
    }

    // Submit form if autoSubmit is true
    if (this.autoSubmitValue) {
      if (this.hasFormTarget) {
        this.formTarget.submit();
      }
    }
  }

  _getInputTargets() {
    return [
      this.num1Target,
      this.num2Target,
      this.num3Target,
      this.num4Target,
      this.num5Target,
      this.num6Target,
    ].filter((target) => target); // Filter out any potentially missing targets
  }

  _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;
  }
}

Examples

Basic Two-Factor Authentication

A complete two-factor authentication form with 6-digit verification code input.

Two-Factor Authentication

Confirm your account by entering the verification code sent to your phone number ending in **432.

-
Haven't received it? Get a new code
<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>
      <h1 class="my-2 text-2xl font-bold">Two-Factor Authentication</h1>
      <h2 class="mb-8 text-sm text-neutral-600 dark:text-neutral-400">
        Confirm your account by entering the verification code sent to
        your phone number ending in **432.
      </h2>
      <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="num1"
            data-action="input->two-factor#handleInput 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="num2"
            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="num3"
            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="num4"
            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="num5"
            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="num6"
            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, input validation, paste support, and auto-submit functionality.

Controller Setup

Basic two-factor authentication structure with required data attributes:

<div data-controller="two-factor">
  <form data-two-factor-target="form" data-action="submit->two-factor#handleSubmit">
    <input data-two-factor-target="num1" data-action="input->two-factor#handleInput paste->two-factor#handlePaste" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code">
    <input data-two-factor-target="num2" 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="num3" 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="num4" 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="num5" 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="num6" 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

Prop Description Type Default
autoSubmit
Automatically submits the form when all 6 digits are entered Boolean false
autofocus
Automatically focuses the first input field when the component connects Boolean true

Targets

Target Description Required
num1
First digit input field Required
num2
Second digit input field Required
num3
Third digit input field Required
num4
Fourth digit input field Required
num5
Fifth digit input field Required
num6
Sixth digit input field Required
form
The form element containing all input fields Required
submitButton
The submit button that gets focused after all inputs are filled Required

Actions

Action Description Usage
handleInput
Handles input validation and automatic navigation to the next field data-action="input->two-factor#handleInput"
handleKeydown
Handles backspace navigation to previous fields data-action="keydown->two-factor#handleKeydown"
handlePaste
Handles pasting of 6-digit codes and auto-filling all fields data-action="paste->two-factor#handlePaste"
handleSubmit
Handles form submission and focus management data-action="submit->two-factor#handleSubmit"

Key Features

  • Automatic Navigation: Moves focus to the next input when a digit is entered
  • Backspace Navigation: Moves focus to the previous input when backspace is pressed on an empty field
  • Paste Support: Automatically fills all fields when a 6-digit code is pasted
  • Input Validation: Only accepts numeric input (0-9)
  • Auto Submit: Optional automatic form submission when all digits are entered
  • Focus Management: Automatic focusing of first input and submit button

Accessibility Features

  • Mobile Optimized: Uses inputmode="numeric" to show numeric keyboard on mobile
  • 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 navigation

Usage Notes

  • Each input should have maxlength="1" to limit to single digits
  • Use type="text" with inputmode="numeric" for better mobile experience
  • The first input should have autocomplete="one-time-code", others should use autocomplete="off"
  • Only the first input needs the paste action - it will handle pasting for all fields

Table of contents