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
Container class
Attributes passed to InputOtp go to the hidden <input> (name, value, required, disabled). Use container_class: to style the wrapper that holds the groups — for example, a wider gap.
Example
InputOtp(length: 4, name: "pin", container_class: "gap-4") do
InputOtpGroup do
InputOtpSlot(index: 0)
InputOtpSlot(index: 1)
end
InputOtpSeparator()
InputOtpGroup do
InputOtpSlot(index: 2)
InputOtpSlot(index: 3)
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
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
CONTAINER_CLASS = "relative flex items-center gap-2 has-disabled:opacity-50"
def initialize(length:, pattern: nil, container_class: nil, **attrs)
@length = length
@char_class = pattern || "[0-9]"
@container_class = container_class
super(**attrs)
end
def view_template(&block)
div(
translate: "no",
data: {
controller: "ruby-ui--input-otp",
ruby_ui__input_otp_length_value: @length,
ruby_ui__input_otp_char_class_value: @char_class
},
class: TAILWIND_MERGER.merge([CONTAINER_CLASS, @container_class].compact.join(" "))
) do
block&.call
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",
spellcheck: "false",
data: {
slot: "input-otp",
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) { "Container class" }
Text { "Attributes passed to InputOtp go to the hidden <input> (name, value, required, disabled). Use container_class: to style the wrapper that holds the groups — for example, a wider gap." }
render Docs::VisualCodeExample.new(title: "Example", context: self) do
<<~RUBY
InputOtp(length: 4, name: "pin", container_class: "gap-4") do
InputOtpGroup do
InputOtpSlot(index: 0)
InputOtpSlot(index: 1)
end
InputOtpSeparator()
InputOtpGroup do
InputOtpSlot(index: 2)
InputOtpSlot(index: 3)
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
{
data: {slot: "input-otp-group"},
class: "flex items-center rounded-md has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40"
}
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",
width: "24",
height: "24",
viewbox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
aria_hidden: "true"
) do |s|
s.path(d: "M5 12h14")
end
end
private
def default_attrs
{
role: "separator",
data: {slot: "input-otp-separator"},
class: "flex items-center [&_svg:not([class*='size-'])]:size-4"
}
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: {
slot: "input-otp-slot",
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 outline-none dark:bg-input/30",
"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-3 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
rake stimulus:manifest:updateComponents
| Component | Built using | Source |
|---|---|---|
InputOtp | Phlex | |
InputOtpDocs | Phlex | |
InputOtpGroup | Phlex | |
InputOtpSeparator | Phlex | |
InputOtpSlot | Phlex | |
InputOtpController | Stimulus JS |