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/vanillaPin @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
render Components::UI::InputOtp.new(name: "user[otp]")
Masked / 4 digits
Pass mask: true to obscure typed characters (like a PIN), and length: to change the slot count.
render Components::UI::InputOtp.new(name: "pin", length: 4, mask: true)
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.