Two Factor Shared Partial

Don't forget the Stimulus controller!
The shared partial still requires the two_factor_controller.js Stimulus controller to be installed. Copy the controller from the Installation section on this page.

1. Copy shared partial files

Download all 1 shared partial files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the two_factor/ folder to your app/views/shared/components/ directory.

Copy these files to your app/views/shared/components/two_factor/ directory:

2. Usage example

<!-- Basic 6-digit OTP input -->
<%= render "shared/components/two_factor/two_factor" %>

<!-- With label -->
<%= render "shared/components/two_factor/two_factor",
  label: "Verification Code" %>

<!-- With label and hint -->
<%= render "shared/components/two_factor/two_factor",
  label: "Enter Code",
  hint: "Enter the 6-digit code sent to your phone" %>

<!-- 4-digit code (PIN style) -->
<%= render "shared/components/two_factor/two_factor",
  length: 4,
  separator: false,
  label: "Enter PIN" %>

<!-- Without separator -->
<%= render "shared/components/two_factor/two_factor",
  separator: false %>

<!-- Separator at different position -->
<%= render "shared/components/two_factor/two_factor",
  separator_position: 2 %>

<!-- Small size -->
<%= render "shared/components/two_factor/two_factor",
  size: "sm",
  label: "Small Code" %>

<!-- Large size -->
<%= render "shared/components/two_factor/two_factor",
  size: "lg",
  label: "Large Code" %>

<!-- Auto-submit when complete -->
<%= render "shared/components/two_factor/two_factor",
  auto_submit: true,
  label: "Auto-submit Code" %>

<!-- Without autofocus -->
<%= render "shared/components/two_factor/two_factor",
  autofocus: false %>

<!-- Allow alphanumeric/symbol codes -->
<%= render "shared/components/two_factor/two_factor",
  numeric_only: false,
  label: "Recovery Code" %>

<!-- With error message -->
<%= render "shared/components/two_factor/two_factor",
  label: "Verification Code",
  error: "Invalid code. Please try again." %>

<!-- Disabled state -->
<%= render "shared/components/two_factor/two_factor",
  label: "Verification Code",
  disabled: true %>

<!-- Custom name for form submission -->
<%= render "shared/components/two_factor/two_factor",
  name: "user[otp_code]",
  label: "Two-Factor Code" %>

<!-- Custom ID prefix -->
<%= render "shared/components/two_factor/two_factor",
  id_prefix: "login_otp",
  label: "Login Code" %>

<!-- With custom classes -->
<%= render "shared/components/two_factor/two_factor",
  label: "Verification Code",
  classes: "max-w-xs mx-auto text-center",
  input_classes: "font-mono" %>

<!-- 8-digit code -->
<%= render "shared/components/two_factor/two_factor",
  length: 8,
  separator_position: 4,
  label: "Backup Code" %>

<!-- Full-featured example -->
<%= render "shared/components/two_factor/two_factor",
  length: 6,
  name: "otp[code]",
  id_prefix: "2fa_code",
  auto_submit: true,
  autofocus: true,
  separator: true,
  separator_position: 3,
  size: "md",
  label: "Two-Factor Authentication",
  hint: "Enter the code from your authenticator app",
  classes: "max-w-sm" %>

Rendered usage example

-
-
-

Enter the 6-digit code sent to your phone

-
-
-
-
-
-
-

Invalid code. Please try again.

-
-
-
-
-
-

Enter the code from your authenticator app

Two Factor ViewComponent

Don't forget the Stimulus controller!
The ViewComponent still requires the two_factor_controller.js Stimulus controller to be installed. Copy the controller from the Installation section on this page.

1. Ensure the gem is installed

gem "view_component", "~> 4.2"

2. Copy component files

Download all 2 component files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the two_factor/ folder to your app/components/ directory.

Copy these files to your app/components/two_factor/ directory:

3. Usage example

<!-- Basic 6-digit OTP input -->
<%= render TwoFactor::Component.new %>

<!-- With label -->
<%= render TwoFactor::Component.new(
  label: "Verification Code"
) %>

<!-- With label and hint -->
<%= render TwoFactor::Component.new(
  label: "Enter Code",
  hint: "Enter the 6-digit code sent to your phone"
) %>

<!-- 4-digit code (PIN style) -->
<%= render TwoFactor::Component.new(
  length: 4,
  separator: false,
  label: "Enter PIN"
) %>

<!-- Without separator -->
<%= render TwoFactor::Component.new(
  separator: false
) %>

<!-- Separator at different position -->
<%= render TwoFactor::Component.new(
  separator_position: 2
) %>

<!-- Small size -->
<%= render TwoFactor::Component.new(
  size: :sm,
  label: "Small Code"
) %>

<!-- Large size -->
<%= render TwoFactor::Component.new(
  size: :lg,
  label: "Large Code"
) %>

<!-- Auto-submit when complete -->
<%= render TwoFactor::Component.new(
  auto_submit: true,
  label: "Auto-submit Code"
) %>

<!-- Without autofocus -->
<%= render TwoFactor::Component.new(
  autofocus: false
) %>

<!-- Allow alphanumeric/symbol codes -->
<%= render TwoFactor::Component.new(
  numeric_only: false,
  label: "Recovery Code"
) %>

<!-- With error message -->
<%= render TwoFactor::Component.new(
  label: "Verification Code",
  error: "Invalid code. Please try again."
) %>

<!-- Disabled state -->
<%= render TwoFactor::Component.new(
  label: "Verification Code",
  disabled: true
) %>

<!-- Custom name for form submission -->
<%= render TwoFactor::Component.new(
  name: "user[otp_code]",
  label: "Two-Factor Code"
) %>

<!-- Custom ID prefix -->
<%= render TwoFactor::Component.new(
  id_prefix: "login_otp",
  label: "Login Code"
) %>

<!-- With custom classes -->
<%= render TwoFactor::Component.new(
  label: "Verification Code",
  classes: "max-w-xs mx-auto text-center",
  input_classes: "font-mono"
) %>

<!-- 8-digit code -->
<%= render TwoFactor::Component.new(
  length: 8,
  separator_position: 4,
  label: "Backup Code"
) %>

<!-- Full-featured example -->
<%= render TwoFactor::Component.new(
  length: 6,
  name: "otp[code]",
  id_prefix: "2fa_code",
  auto_submit: true,
  autofocus: true,
  separator: true,
  separator_position: 3,
  size: :md,
  label: "Two-Factor Authentication",
  hint: "Enter the code from your authenticator app",
  classes: "max-w-sm"
) %>

Rendered usage example

-
-
-

Enter the 6-digit code sent to your phone

-
-
-
-
-
-
-

Invalid code. Please try again.

-
-
-
-
-
-

Enter the code from your authenticator app

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.

Two-Factor Authentication

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

Prop Description Type Default
autoSubmit
Automatically submits the form when all input fields are filled Boolean false
autofocus
Automatically focuses the first input field when the component connects Boolean true
numericOnly
Restricts each input to digits only (set false to allow any character) Boolean true

Targets

Target Description Required
digit
Repeated target for each code input field Required
form
Form element used for auto-submit mode Optional
submitButton
Submit button focused after code completion Optional

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 and arrow key navigation between fields data-action="keydown->two-factor#handleKeydown"
handlePaste
Handles pasting codes and fills available input 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 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 to inputmode="text" when numericOnly is 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" with inputmode="numeric" for numeric OTPs, or data-two-factor-numeric-only-value="false" with inputmode="text" for alphanumeric codes
  • The first input should have autocomplete="one-time-code", others should use autocomplete="off"
  • Use the same input actions on every digit field: input, keydown, and paste

Table of contents

Powered by

Get notified when new components come out