ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

Input OTP

One-time-code / PIN input — N single-character slots that submit one concatenated value.

Installation

bin/rails g wabi:add input_otp
bin/importmap pin @zag-js/pin-input @zag-js/vanilla

Pin @zag-js/pin-input and @zag-js/vanilla at 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only fetches the main entry and leaves submodules unresolved.

Example

Masked / 4 digits

Pass mask: true to obscure typed characters (like a PIN), and length: to change the slot count.

Source

app/components/ui/input_otp.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class InputOtp < Wabi::Base
      SLOT_CLASS =
        "h-10 w-10 rounded-md border border-input bg-background text-center text-base " \
        "shadow-sm transition-colors motion-reduce:transition-none outline-none " \
        "focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background " \
        "disabled:cursor-not-allowed disabled:opacity-50"

      def initialize(name: nil, length: 6, type: :numeric, mask: false, otp: true,
                     default_value: nil, placeholder: "○", disabled: false,
                     invalid: false, required: false,
                     aria_label: "One-time passcode", **attrs)
        @name          = name
        @length        = length
        @type          = type
        @mask          = mask
        @otp           = otp
        @default_value = default_value
        @placeholder   = placeholder
        @disabled      = disabled
        @invalid       = invalid
        @required      = required
        @aria_label    = aria_label
        @attrs         = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        user_data  = @attrs.delete(:data) || {}
        root_data = {
          controller: "wabi--input-otp",
          "wabi--input-otp-name-value":          @name,
          "wabi--input-otp-length-value":        @length.to_s,
          "wabi--input-otp-type-value":          @type.to_s,
          "wabi--input-otp-mask-value":          @mask.to_s,
          "wabi--input-otp-otp-value":           @otp.to_s,
          "wabi--input-otp-default-value-value": @default_value.to_s,
          "wabi--input-otp-disabled-value":      @disabled.to_s,
          "wabi--input-otp-invalid-value":       @invalid.to_s,
          "wabi--input-otp-required-value":      @required.to_s,
        }
        # aria-label names the group of slots; each slot also gets a per-digit
        # aria-label at runtime via the controller's Zag `translations.inputLabel`.
        div(**@attrs, "aria-label": @aria_label, data: user_data.merge(root_data),
            class: merge_class("inline-flex items-center gap-2", user_class)) do
          @length.times do
            input(
              type: "text", inputmode: @type == :numeric ? "numeric" : "text",
              autocomplete: @otp ? "one-time-code" : "off",
              placeholder: @placeholder,
              data: { "wabi--input-otp-target": "slot" },
              class: SLOT_CLASS
            )
          end
          input(type: "hidden", name: @name, data: { "wabi--input-otp-target": "hiddenValue" })
        end
      end
    end
  end
end

app/javascript/controllers/wabi/input_otp_controller.js

import { Controller } from "@hotwired/stimulus"
import * as pinInput from "@zag-js/pin-input"
import { VanillaMachine, normalizeProps, spreadProps } from "@zag-js/vanilla"

export default class extends Controller {
  static targets = ["slot", "hiddenValue"]
  static values = {
    name:         String,
    length:       { type: Number, default: 6 },
    type:         { type: String, default: "numeric" },
    mask:         { type: Boolean, default: false },
    otp:          { type: Boolean, default: true },
    defaultValue: String,
    disabled:     { type: Boolean, default: false },
    invalid:      { type: Boolean, default: false },
    required:     { type: Boolean, default: false },
  }

  connect() {
    this.machine = new VanillaMachine(pinInput.machine, {
      id: this.element.id || crypto.randomUUID(),
      count: this.lengthValue,
      type: this.typeValue,
      mask: this.maskValue,
      otp: this.otpValue,
      disabled: this.disabledValue,
      invalid: this.invalidValue,
      required: this.requiredValue,
      // defaultValue is an array of characters; split the string value when set.
      // Stimulus String values default to ""  treat empty string as unset.
      defaultValue: this.defaultValueValue !== "" ? this.defaultValueValue.split("") : undefined,
      // Give each slot a distinct accessible name; without translations Zag emits
      // aria-label: undefined on every input.
      translations: { inputLabel: (index, length) => `Digit ${index + 1} of ${length}` },
      onValueChange: ({ valueAsString }) => {
        this.syncHidden(valueAsString)
        this.dispatch("change", { detail: { value: valueAsString } })
      },
    })
    this.unsubscribe = this.machine.subscribe(() => this.render())
    this.machine.start()
    this.render()
    // Sync the hidden input immediately (covers defaultValue pre-fill)
    this.syncHidden(this.api.valueAsString)
  }

  disconnect() {
    this.unsubscribe?.()
    this.machine?.stop()
  }

  get api() {
    return pinInput.connect(this.machine.service, normalizeProps)
  }

  render() {
    const api = this.api
    spreadProps(this.element, api.getRootProps())
    this.slotTargets.forEach((el, index) => spreadProps(el, api.getInputProps({ index })))
  }

  syncHidden(value) {
    if (this.hasHiddenValueTarget) this.hiddenValueTarget.value = value ?? ""
  }
}

Accessibility

  • Each slot renders as a text input; arrow keys move focus between slots.
  • autocomplete="one-time-code" is set by default (otp: true) for browser autofill.
  • Paste is supported — the controller distributes pasted characters across slots.
  • The assembled value is mirrored to a hidden input so the named field submits correctly.
  • mask: true switches each slot to type="password" rendering without changing the underlying API.