Input OTP
Accessible one-time-password input with keyboard navigation and paste support.
Usage
Example
InputOtp(length: 6, name: "otp") do InputOtpGroup do InputOtpSlot(index: 0) InputOtpSlot(index: 1) InputOtpSlot(index: 2) InputOtpSlot(index: 3) InputOtpSlot(index: 4) InputOtpSlot(index: 5) end end
Composition
InputOtpGroup and InputOtpSeparator compose freely — split slots into however many groups make sense, with a separator between each.
Example
InputOtp(length: 6, name: "otp") do InputOtpGroup do InputOtpSlot(index: 0) InputOtpSlot(index: 1) end InputOtpSeparator() InputOtpGroup do InputOtpSlot(index: 2) InputOtpSlot(index: 3) end InputOtpSeparator() InputOtpGroup do InputOtpSlot(index: 4) InputOtpSlot(index: 5) end end
Pattern
Pass pattern: with a single-character regex class to define what InputOtp accepts. The default is "[0-9]" (digits only).
Alphanumeric
InputOtp(length: 6, name: "otp", pattern: "[0-9A-Za-z]") do InputOtpGroup do InputOtpSlot(index: 0) InputOtpSlot(index: 1) InputOtpSlot(index: 2) InputOtpSlot(index: 3) InputOtpSlot(index: 4) InputOtpSlot(index: 5) end end
Four digits
A common pattern for PIN codes — just pass a shorter length.
Example
InputOtp(length: 4, name: "pin") do InputOtpGroup do InputOtpSlot(index: 0) InputOtpSlot(index: 1) InputOtpSlot(index: 2) InputOtpSlot(index: 3) end end
Disabled
Example
InputOtp(length: 6, name: "otp", disabled: true) do InputOtpGroup do InputOtpSlot(index: 0) InputOtpSlot(index: 1) InputOtpSlot(index: 2) InputOtpSlot(index: 3) InputOtpSlot(index: 4) InputOtpSlot(index: 5) end end
Invalid
Pass aria_invalid: "true" to each InputOtpSlot to show an error state.
Example
InputOtp(length: 6, name: "otp") do InputOtpGroup do InputOtpSlot(index: 0, aria_invalid: "true") InputOtpSlot(index: 1, aria_invalid: "true") InputOtpSlot(index: 2, aria_invalid: "true") InputOtpSlot(index: 3, aria_invalid: "true") InputOtpSlot(index: 4, aria_invalid: "true") InputOtpSlot(index: 5, aria_invalid: "true") end end
Form
A full example combining InputOtp with Card, Button, and InlineLink — bigger slots via class:, a resend action, and fallback links.
Verify your login
Verify your login
Enter the verification code we sent to your email address: [email protected].
Having trouble signing in? Contact support
Card(class: "mx-auto max-w-md") do CardHeader do CardTitle { "Verify your login" } CardDescription do plain "Enter the verification code we sent to your email address: " span(class: "font-medium") { "[email protected]" } plain "." end end CardContent(class: "space-y-4") do div(class: "flex items-center justify-between") do label(class: "text-sm font-medium") { "Verification code" } Button(variant: :outline, size: :sm) do svg( xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "w-4 h-4 mr-2" ) do |s| s.path(d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8") s.path(d: "M21 3v5h-5") s.path(d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16") s.path(d: "M8 16H3v5") end plain "Resend Code" end end InputOtp(length: 6, name: "otp", required: true) do InputOtpGroup do InputOtpSlot(index: 0, class: "h-12 w-11 text-xl") InputOtpSlot(index: 1, class: "h-12 w-11 text-xl") InputOtpSlot(index: 2, class: "h-12 w-11 text-xl") end InputOtpSeparator(class: "mx-2") InputOtpGroup do InputOtpSlot(index: 3, class: "h-12 w-11 text-xl") InputOtpSlot(index: 4, class: "h-12 w-11 text-xl") InputOtpSlot(index: 5, class: "h-12 w-11 text-xl") end end InlineLink(href: "#") { "I no longer have access to this email address." } end CardFooter(class: "flex flex-col items-stretch gap-2") do Button(class: "w-full") { "Verify" } Text(size: "sm", weight: "muted") do plain "Having trouble signing in? " InlineLink(href: "#") { "Contact support" } end end end
Reacting to completion
The controller dispatches a ruby-ui--input-otp:complete custom event (detail: { value }) once the value reaches length characters, and a ruby-ui--input-otp:input event on every change. Wire a Stimulus action on a parent element to react — for example, to auto-submit a form:
// app/javascript/controllers/otp_form_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { submit() { this.element.requestSubmit() } }
<form data-controller="otp-form" data-action="ruby-ui--input-otp:complete->otp-form#submit"> <!-- InputOtp(...) here --> </form>
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component InputOtp
Manual installation
1
Add RubyUI::InputOtp to app/components/ruby_ui/input_otp/input_otp.rb
# frozen_string_literal: true module RubyUI class InputOtp < Base def initialize(length:, pattern: nil, **attrs) @length = length @char_class = pattern || "[0-9]" super(**attrs) end def view_template(&block) div( data: { controller: "ruby-ui--input-otp", ruby_ui__input_otp_length_value: @length, ruby_ui__input_otp_char_class_value: @char_class }, class: "relative flex items-center has-disabled:opacity-50" ) do div(class: "flex items-center gap-2", &block) input(**attrs) end end private def default_attrs { type: "text", inputmode: (@char_class == "[0-9]") ? "numeric" : "text", pattern: "^(?:#{@char_class}){#{@length}}$", maxlength: @length, autocomplete: "one-time-code", data: { ruby_ui__input_otp_target: "input", action: "input->ruby-ui--input-otp#onInput keydown->ruby-ui--input-otp#onKeydown paste->ruby-ui--input-otp#onPaste focus->ruby-ui--input-otp#onFocus blur->ruby-ui--input-otp#onBlur" }, class: "absolute inset-0 h-full w-full cursor-text appearance-none border-0 bg-transparent p-0 text-transparent caret-transparent shadow-none outline-none selection:bg-transparent focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed" } end end end
2
Add RubyUI::InputOtpDocs to app/components/ruby_ui/input_otp/input_otp_docs.rb
# frozen_string_literal: true class Views::Docs::InputOtp < Views::Base def view_template component = "InputOtp" div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do render Docs::Header.new(title: "Input OTP", description: "Accessible one-time-password input with keyboard navigation and paste support.") Heading(level: 2) { "Usage" } render Docs::VisualCodeExample.new(title: "Example", context: self) do <<~RUBY InputOtp(length: 6, name: "otp") do InputOtpGroup do InputOtpSlot(index: 0) InputOtpSlot(index: 1) InputOtpSlot(index: 2) InputOtpSlot(index: 3) InputOtpSlot(index: 4) InputOtpSlot(index: 5) end end RUBY end Heading(level: 2) { "Composition" } Text { "InputOtpGroup and InputOtpSeparator compose freely — split slots into however many groups make sense, with a separator between each." } render Docs::VisualCodeExample.new(title: "Example", context: self) do <<~RUBY InputOtp(length: 6, name: "otp") do InputOtpGroup do InputOtpSlot(index: 0) InputOtpSlot(index: 1) end InputOtpSeparator() InputOtpGroup do InputOtpSlot(index: 2) InputOtpSlot(index: 3) end InputOtpSeparator() InputOtpGroup do InputOtpSlot(index: 4) InputOtpSlot(index: 5) end end RUBY end Heading(level: 2) { "Pattern" } Text { "Pass pattern: with a single-character regex class to define what InputOtp accepts. The default is \"[0-9]\" (digits only)." } render Docs::VisualCodeExample.new(title: "Alphanumeric", context: self) do <<~RUBY InputOtp(length: 6, name: "otp", pattern: "[0-9A-Za-z]") do InputOtpGroup do InputOtpSlot(index: 0) InputOtpSlot(index: 1) InputOtpSlot(index: 2) InputOtpSlot(index: 3) InputOtpSlot(index: 4) InputOtpSlot(index: 5) end end RUBY end Heading(level: 2) { "Four digits" } Text { "A common pattern for PIN codes — just pass a shorter length." } render Docs::VisualCodeExample.new(title: "Example", context: self) do <<~RUBY InputOtp(length: 4, name: "pin") do InputOtpGroup do InputOtpSlot(index: 0) InputOtpSlot(index: 1) InputOtpSlot(index: 2) InputOtpSlot(index: 3) end end RUBY end Heading(level: 2) { "Disabled" } render Docs::VisualCodeExample.new(title: "Example", context: self) do <<~RUBY InputOtp(length: 6, name: "otp", disabled: true) do InputOtpGroup do InputOtpSlot(index: 0) InputOtpSlot(index: 1) InputOtpSlot(index: 2) InputOtpSlot(index: 3) InputOtpSlot(index: 4) InputOtpSlot(index: 5) end end RUBY end Heading(level: 2) { "Invalid" } Text { "Pass aria_invalid: \"true\" to each InputOtpSlot to show an error state." } render Docs::VisualCodeExample.new(title: "Example", context: self) do <<~RUBY InputOtp(length: 6, name: "otp") do InputOtpGroup do InputOtpSlot(index: 0, aria_invalid: "true") InputOtpSlot(index: 1, aria_invalid: "true") InputOtpSlot(index: 2, aria_invalid: "true") InputOtpSlot(index: 3, aria_invalid: "true") InputOtpSlot(index: 4, aria_invalid: "true") InputOtpSlot(index: 5, aria_invalid: "true") end end RUBY end Heading(level: 2) { "Form" } Text { "A full example combining InputOtp with Card, Button, and InlineLink — bigger slots via class:, a resend action, and fallback links." } render Docs::VisualCodeExample.new(title: "Verify your login", context: self) do <<~RUBY Card(class: "mx-auto max-w-md") do CardHeader do CardTitle { "Verify your login" } CardDescription do plain "Enter the verification code we sent to your email address: " span(class: "font-medium") { "[email protected]" } plain "." end end CardContent(class: "space-y-4") do div(class: "flex items-center justify-between") do label(class: "text-sm font-medium") { "Verification code" } Button(variant: :outline, size: :sm) do svg( xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "w-4 h-4 mr-2" ) do |s| s.path(d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8") s.path(d: "M21 3v5h-5") s.path(d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16") s.path(d: "M8 16H3v5") end plain "Resend Code" end end InputOtp(length: 6, name: "otp", required: true) do InputOtpGroup do InputOtpSlot(index: 0, class: "h-12 w-11 text-xl") InputOtpSlot(index: 1, class: "h-12 w-11 text-xl") InputOtpSlot(index: 2, class: "h-12 w-11 text-xl") end InputOtpSeparator(class: "mx-2") InputOtpGroup do InputOtpSlot(index: 3, class: "h-12 w-11 text-xl") InputOtpSlot(index: 4, class: "h-12 w-11 text-xl") InputOtpSlot(index: 5, class: "h-12 w-11 text-xl") end end InlineLink(href: "#") { "I no longer have access to this email address." } end CardFooter(class: "flex flex-col items-stretch gap-2") do Button(class: "w-full") { "Verify" } Text(size: "sm", weight: "muted") do plain "Having trouble signing in? " InlineLink(href: "#") { "Contact support" } end end end RUBY end Heading(level: 2) { "Reacting to completion" } Text { "The controller dispatches a ruby-ui--input-otp:complete custom event (detail: { value }) once the value reaches length characters, and a ruby-ui--input-otp:input event on every change. Wire a Stimulus action on a parent element to react — for example, to auto-submit a form:" } Codeblock(<<~JS, syntax: :javascript) // app/javascript/controllers/otp_form_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { submit() { this.element.requestSubmit() } } JS Codeblock(<<~HTML, syntax: :html) <form data-controller="otp-form" data-action="ruby-ui--input-otp:complete->otp-form#submit"> <!-- InputOtp(...) here --> </form> HTML render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) end end end
3
Add RubyUI::InputOtpGroup to app/components/ruby_ui/input_otp/input_otp_group.rb
# frozen_string_literal: true module RubyUI class InputOtpGroup < Base def view_template(&block) div(**attrs, &block) end private def default_attrs {class: "flex items-center"} end end end
4
Add RubyUI::InputOtpSeparator to app/components/ruby_ui/input_otp/input_otp_separator.rb
# frozen_string_literal: true module RubyUI class InputOtpSeparator < Base def view_template(&block) div(**attrs) do if block block.call else icon end end end def icon svg( xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "h-4 w-4" ) do |s| s.path(d: "M5 12h14") end end private def default_attrs { role: "separator", class: "text-muted-foreground" } end end end
5
Add RubyUI::InputOtpSlot to app/components/ruby_ui/input_otp/input_otp_slot.rb
# frozen_string_literal: true module RubyUI class InputOtpSlot < Base def initialize(index:, **attrs) @index = index super(**attrs) end def view_template div(**attrs) end private def default_attrs { aria_hidden: "true", data: { ruby_ui__input_otp_target: "slot", index: @index }, class: [ "relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-xs transition-all", "first:rounded-l-md first:border-l last:rounded-r-md", "data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-2 data-[active=true]:ring-ring/50", "data-[caret=true]:after:content-[''] data-[caret=true]:after:absolute data-[caret=true]:after:h-4 data-[caret=true]:after:w-px data-[caret=true]:after:animate-caret-blink data-[caret=true]:after:bg-foreground", "aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40" ] } end end end
6
Add input_otp_controller.js to app/javascript/controllers/ruby_ui/input_otp_controller.js
import { Controller } from "@hotwired/stimulus" // Connects to data-controller="ruby-ui--input-otp" export default class extends Controller { static targets = ["input", "slot"] static values = { length: Number, charClass: String } connect() { // A server-rendered value (prefilled from a previous submission, a // validation error, etc.) may exceed length or contain characters that // fail pattern. Sanitize it up front so the hidden slots never mask // an invalid/oversized value that would otherwise still get submitted. this.sanitizeValue() this.paint() this.boundOnSelectionChange = this.onSelectionChange.bind(this) document.addEventListener("selectionchange", this.boundOnSelectionChange) } disconnect() { document.removeEventListener("selectionchange", this.boundOnSelectionChange) } onInput() { this.sanitizeValue() const filtered = this.inputTarget.value this.normalizeSelection() this.paint() this.dispatch("input", { detail: { value: filtered } }) if (filtered.length === this.lengthValue) { this.dispatch("complete", { detail: { value: filtered } }) } } onFocus() { const end = this.inputTarget.value.length const start = Math.min(end, this.lengthValue - 1) this.inputTarget.setSelectionRange(start, end) this.paint() } onBlur() { this.paint() } onKeydown(event) { const moves = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 } if (!(event.key in moves)) return event.preventDefault() const current = this.inputTarget.selectionStart ?? 0 const next = Math.min(Math.max(current + moves[event.key], 0), this.lengthValue - 1) const hasChar = next < this.inputTarget.value.length this.inputTarget.setSelectionRange(next, hasChar ? next + 1 : next) this.paint() } onPaste(event) { event.preventDefault() const pasted = this.filter(event.clipboardData.getData("text/plain")) if (!pasted) return const start = this.inputTarget.selectionStart ?? 0 const end = this.inputTarget.selectionEnd ?? start const current = this.inputTarget.value const merged = (current.slice(0, start) + pasted + current.slice(end)).slice(0, this.lengthValue) this.inputTarget.value = merged const caret = Math.min(merged.length, this.lengthValue - 1) this.inputTarget.setSelectionRange(caret, merged.length) this.paint() this.dispatch("input", { detail: { value: merged } }) if (merged.length === this.lengthValue) this.dispatch("complete", { detail: { value: merged } }) } onSelectionChange() { if (document.activeElement !== this.inputTarget) return this.paint() } // After typing, replacing, or deleting, the browser leaves a collapsed // caret. If it landed on a slot that already has a character (not the // true insert-mode end of the value), re-select that character as a // 1-char range so the next keystroke replaces it instead of being // silently dropped by the native maxlength/no-selection behavior. normalizeSelection() { const input = this.inputTarget const value = input.value const s = input.selectionStart const e = input.selectionEnd if (s === null || e === null || s !== e) return const isInsertMode = s === value.length && value.length < this.lengthValue if (isInsertMode) return const index = Math.min(s, this.lengthValue - 1) input.setSelectionRange(index, index < value.length ? index + 1 : index) } filter(raw) { const re = new RegExp(this.charClassValue) return raw.split("").filter((char) => re.test(char)).join("") } sanitizeValue() { const filtered = this.filter(this.inputTarget.value).slice(0, this.lengthValue) if (filtered !== this.inputTarget.value) this.inputTarget.value = filtered } paint() { const value = this.inputTarget.value const isFocused = document.activeElement === this.inputTarget const start = this.inputTarget.selectionStart ?? value.length const end = this.inputTarget.selectionEnd ?? value.length const activeIndex = Math.min(start, this.lengthValue - 1) this.slotTargets.forEach((slot) => { const index = Number(slot.dataset.index) const char = value[index] ?? "" const isActive = isFocused && ((start === end && index === activeIndex) || (index >= start && index < end)) slot.textContent = char slot.dataset.active = isActive ? "true" : "false" slot.dataset.caret = isActive && char === "" ? "true" : "false" }) } }
7
Update the Stimulus controllers manifest file
Importmap!
rake stimulus:manifest:update
Components
| Component | Built using | Source |
|---|---|---|
InputOtp | Phlex | |
InputOtpDocs | Phlex | |
InputOtpGroup | Phlex | |
InputOtpSeparator | Phlex | |
InputOtpSlot | Phlex | |
InputOtpController | Stimulus JS |