Input Otp
A fixed-length, segmented input for one-time passcodes.
Installation
Included in poetry-ui — available as
poetry_input_otp
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add input-otp
Default
<%# One native input over aria-hidden cells - paste, SMS autofill, and
IME all work natively. 6 digits as two groups with a separator. %>
<%= poetry_input_otp(name: "code", length: 6, groups: [3, 3],
"aria-label": "Verification code") %>
Alphanumeric
<%# pattern: :alphanumeric filters each char to [a-zA-Z0-9] and switches
to inputmode=text (the default :digits shows the numeric keypad). %>
<%= poetry_input_otp(name: "code", length: 6, pattern: :alphanumeric,
"aria-label": "Verification code") %>
Form
Verify your login
<%# Upstream input-otp-form: the verify-login card. The resend action is
an xs outline button ON the label row (upstream's badge-sized chip);
the lost-access link is the muted description under the code; the
footer stacks the full-width submit over the support line. Slot
sizing is upstream's own override recipe, expressed as descendant
data-slot variants through the root class seam. w-min makes the card
hug the code row - the OTP is the widest unbreakable child, so label,
resend, and description all align to the input's edges (upstream gets
the same shrink from its preview flexbox). %>
<%= poetry_card(class: "mx-auto w-min") do |card| %>
<% card.with_title { "Verify your login" } %>
<% card.with_description do %>
Enter the verification code we sent to your email address:
<span class="font-medium">m@example.com</span>.
<% end %>
<div class="grid gap-3">
<div class="flex items-center justify-between">
<%= poetry_label(for_id: "otp-verification") { "Verification code" } %>
<%= poetry_button(variant: :outline, size: :xs, type: :button) do %>
<%= poetry_icon(name: :"refresh-cw") %> Resend Code
<% end %>
</div>
<%= poetry_input_otp(name: "code", length: 6, groups: [3, 3], required: true,
id: "otp-verification",
"aria-describedby": "otp-verification-description",
class: "**:data-[slot=input-otp-slot]:h-12 " \
"**:data-[slot=input-otp-slot]:w-11 " \
"**:data-[slot=input-otp-slot]:text-xl " \
"**:data-[slot=input-otp-separator]:mx-2") %>
<p id="otp-verification-description" class="text-sm text-muted-foreground">
<%= poetry_link(href: "#") { "I no longer have access to this email address." } %>
</p>
</div>
<% card.with_footer do %>
<div class="grid w-full gap-3">
<%= poetry_button(type: :submit, class: "w-full") { "Verify" } %>
<div class="text-sm text-muted-foreground">
Having trouble signing in? <%= poetry_link(href: "#") { "Contact support" } %>
</div>
</div>
<% end %>
<% end %>
Four digits
<%# A short 4-digit PIN. length can be any fixed size - here four cells
instead of six. No groups: means one contiguous cluster, so no
separator dash is rendered. Digits is the default pattern (numeric
keypad on mobile). %>
<%= poetry_input_otp(name: "pin", length: 4,
"aria-label": "PIN") %>
Groupings
<div class="flex flex-col gap-6">
<%# One group of six - no separator rendered for a single group. %>
<%= poetry_input_otp(name: "code_single", length: 6,
"aria-label": "Verification code") %>
<%# groups must sum to length: [2, 2, 2] gives three pairs. %>
<%= poetry_input_otp(name: "code_pairs", length: 6, groups: [2, 2, 2],
"aria-label": "Verification code") %>
</div>
In a field
Enter the 6-digit code we sent to your phone.
<%# Field-bound: control_attributes lands the id (the label-for target)
and aria-describedby on the INPUT - the one real control. Put the
code length in the hint, not the label. %>
<% field = Poetry::Ui::Field::Component.new(
id: "otp-code", label_text: "Verification code",
hint: "Enter the 6-digit code we sent to your phone."
) %>
<div class="w-80">
<%= render field do %>
<%= poetry_input_otp(name: "code", length: 6, groups: [3, 3],
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
</div>
States
<div class="flex flex-col gap-6">
<%# Server-rendered partial value (dummy only - never render a real
code: the value is a live credential). %>
<%= poetry_input_otp(name: "code_partial", length: 6, groups: [3, 3],
value: "12", "aria-label": "Verification code") %>
<%= poetry_input_otp(name: "code_disabled", length: 6, groups: [3, 3],
disabled: true, "aria-label": "Verification code") %>
<%# The failed-verify re-render: aria-invalid on the input, destructive
cells, and the value blanked - a rejected code is dead. %>
<%= poetry_input_otp(name: "code_invalid", length: 6, groups: [3, 3],
invalid: true, "aria-label": "Verification code") %>
</div>
API
Poetry::Ui::InputOtp::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| disabled: | Boolean | defaults to false |
Disables the native input (the whole row dims). |
| groups: | Object | Cell clustering, e.g. [3, 3] -> two groups with a separator. | |
| invalid: | Boolean | defaults to false |
aria-invalid on the input; the cells mirror the destructive treatment (set by Field/FormBuilder from the failed verify). |
| length: | Integer | required | Code length = slot count = maxlength. |
| name: | String | required | The ONE input serializes params[name] = the code string. |
| pattern: | Object | defaults to :digits |
:digits (numeric keypad) | :alphanumeric | a custom Regexp - the per-char filter + the native pattern attribute + inputmode. |
| required: | Boolean | defaults to false |
aria-required on the input - never native required (the Field rule: required rides server-side validation + aria). |
| separator: | Boolean | defaults to true |
role=separator dash between groups (meaningful with 2+ groups). |
| value: | String | Current code (server-rendered into the input AND the cells). The FormBuilder deliberately never round-trips it (a rejected code is dead). |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=input-otp-container] | Root row (forced dir=ltr - slot order equals string index order even on RTL pages) wrapping the real input and the mirror cells |
| [data-slot=input-otp] | THE real native <input> (autocomplete one-time-code) stretched invisibly over the row - the only AT and serialization surface |
| [data-slot=input-otp-group] | One aria-hidden cluster of mirror cells (groups: clustering) |
| [data-slot=input-otp-slot] | One presentational mirror cell - paints its char and the active-cell ring |
| [data-slot=input-otp-caret] | The fake-caret overlay - hidden server-side; the controller unhides it on the active EMPTY cell |
| [data-slot=input-otp-separator] | The between-groups dash - role=separator kept for parity but aria-hidden (a recorded divergence) |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| input-otp-slot | data-active | "true" while the native caret sits on this cell (the controller projects selectionStart while the input is focused; the server renders "false") | true · false |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--otp | registers · value length · value pattern · focusInput on click |
| input | poetry--core--otp | sync on input/focus/blur · paste on paste · target input |
| slot | poetry--core--otp | target slot |