OTP Input
stimeo--otp
A one-time code entered digit by digit, splitting a paste or an autofill across the boxes.
Enters a verification code across one box per digit. The number of digits is simply how many boxes you rendered, so there is nothing to configure. Typing a character moves on to the next box, Backspace clears the current one, and from an empty box it steps back and clears that. Pasting the whole code, an operating system filling in the code it received, a password manager, or a Japanese input method confirming several characters at once are all split across the boxes. A character that is not accepted returns the box to what it held. Only when nothing at all could be filled is that reported, and completing every box is reported too.
Keyboard
| Key | Action |
|---|---|
| Backspace | Clears the focused field in place. If it is already empty, moves focus to the previous field and clears that one. |
| ← | Moves focus to the previous input field. Under RTL this is ArrowRight. |
| → | Moves focus to the next input field. Under RTL this is ArrowLeft. |
| Home | Moves focus to the first input field. Chorded (Control+Home) it is left to the document. |
| End | Moves focus to the last input field. Chorded (Control+End) it is left to the document. |
<%# Markup for the otp (OTP PIN passcode) demo.
Provides auto-advance on entry in each field, Backspace to clear, paste and
autofill splitting, and value sync. The digit count is the number of fields. %>
<div
class="otp-demo"
id="otp-passcode"
data-controller="stimeo--otp"
data-stimeo--otp-pattern-value="[0-9]"
role="group"
aria-label="<%= t("components.otp.demo.label") %>"
>
<div class="otp-demo__label"><%= t("components.otp.demo.label") %></div>
<div class="otp-demo__fields">
<% 6.times do |i| %>
<input
class="otp-demo__field"
type="text"
inputmode="numeric"
maxlength="1"
autocomplete="one-time-code"
data-stimeo--otp-target="field"
data-action="
input->stimeo--otp#onInput
keydown->stimeo--otp#onKeydown
paste->stimeo--otp#onPaste
pointerdown->stimeo--otp#onPointerDown
"
aria-label="<%= t("components.otp.demo.digit_label", position: i + 1) %>"
/>
<% end %>
</div>
<input
type="hidden"
data-stimeo--otp-target="value"
name="otp"
/>
<%# Region for invalid-input / full-width-character warning messages. %>
<div
data-stimeo--otp-target="error"
id="otp-error-message"
class="otp-demo__error"
role="alert"
aria-live="polite"
hidden
>
<span class="otp-demo__error-badge">!</span>
<span class="otp-demo__error-text">
<%= t("components.otp.demo.invalid_msg") %>
</span>
</div>
<%# The completion message is shown by CSS reading data-state on the root, so the
demo script only has to fill in the code itself. %>
<div id="otp-complete-message" class="otp-demo__message">
<span class="otp-demo__success-badge">✓</span>
<span class="otp-demo__success-text">
<%= t("components.otp.demo.complete_msg") %>
<strong id="otp-success-code"></strong>
</span>
</div>
<button type="button" class="demo-trigger otp-demo__clear" data-action="click->stimeo--otp#clear">
<%= t("components.otp.demo.clear") %>
</button>
</div>
/*
* Presentation-only styles for the otp demo.
* Whether a field is filled switches on data-filled, and whether the passcode is
* finished switches on data-state (both set by the library).
*/
.otp-demo {
display: inline-block;
font-family: inherit;
}
.otp-demo__label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.otp-demo__fields {
display: flex;
gap: 0.5rem;
}
.otp-demo__field {
width: 3rem;
height: 3.5rem;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
color: var(--fg);
background: var(--surface-card);
border: 1px solid var(--border-strong);
border-radius: 8px;
outline: none;
transition: all 0.15s ease;
}
/* Filled state (data-filled). */
.otp-demo__field[data-filled="true"] {
border-color: var(--border-interactive);
}
/* Focus state. */
.otp-demo__field:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.15);
}
/* The completion message follows data-state="complete" on the group, so clearing a
digit hides it again without any script. */
.otp-demo__message {
display: none;
margin-top: 1rem;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--leaf-50);
border: 1px solid var(--leaf-50);
border-radius: 8px;
animation: otpFadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.otp-demo[data-state="complete"] .otp-demo__message {
display: flex;
}
.otp-demo__clear {
margin-top: 1rem;
}
.otp-demo__success-badge {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--leaf-500);
color: var(--white);
font-size: 0.75rem;
font-weight: bold;
}
.otp-demo__success-text {
font-size: 0.875rem;
color: var(--leaf-500);
}
.otp-demo__error {
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--danger-50);
border: 1px solid var(--ruby-200);
border-radius: 8px;
animation: otpFadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Stimeo controls visibility only via the hidden attribute (behavior only); the styling reads
that state. */
.otp-demo__error[hidden] {
display: none;
}
.otp-demo__error-badge {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--danger-500);
color: var(--white);
font-size: 0.75rem;
font-weight: bold;
}
.otp-demo__error-text {
font-size: 0.875rem;
color: var(--color-accent);
}
@keyframes otpFadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Demo script that fills the celebration message with the completed passcode.
// Whether that message is shown is CSS reading data-state on the group, so the
// script never has to track emptying, correcting, or clearing the code.
document.getElementById('otp-passcode').addEventListener('stimeo--otp:complete', function (e) {
const code = document.getElementById('otp-success-code');
if (code) code.textContent = e.detail.value;
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--otp"
Targets
| Name | Description | Attribute |
|---|---|---|
field
required
|
A single-character digit input; one per slot, and their count is the passcode length. | data-stimeo--otp-target="field" |
value
|
Hidden input that holds the concatenated code for form submission. | data-stimeo--otp-target="value" |
error
|
Element shown/hidden to surface input that filled no field at all. | data-stimeo--otp-target="error" |
Values
| Name | Description | Attribute |
|---|---|---|
pattern
|
Per-character validation regex (default [0-9]). A declaration that cannot compile falls back to the default. |
data-stimeo--otp-pattern-value |
Actions
| Name | Description | Action |
|---|---|---|
onInput
|
Validates the typed or autofilled text, spreads what it accepts across the following fields, and advances focus. | stimeo--otp#onInput |
onKeydown
|
Handles Backspace clearing/stepping back and Arrow/Home/End navigation. | stimeo--otp#onKeydown |
onPaste
|
Intercepts a paste and distributes its valid characters across the fields. | stimeo--otp#onPaste |
onPointerDown
|
Sends a pointer landing on an empty field to the earliest empty one so the code fills in order. | stimeo--otp#onPointerDown |
clear
|
Empties every writable field and returns focus to the first one, for a "start over" control. | stimeo--otp#clear |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched whenever the combined value actually changes, with that value in detail. | stimeo--otp:change |
complete
|
Dispatched when the combined value changes with every field filled, with the full value in detail. | stimeo--otp:complete |
invalid
|
Dispatched when input filled no field at all, with the effective pattern in detail. | stimeo--otp:invalid |
reconcile
|
Dispatched when adding or removing fields moves the value, so automation never reads a re-render as an edit. | stimeo--otp:reconcile |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-filled |
individual input field | "true" when a digit is filled, removed otherwise. |
data-state |
controller element | "empty", "partial", or "complete" — the entry state of the whole passcode. |
aria-invalid / aria-errormessage |
individual input field | Written while discarded input is being reported, and returned to the authored value once input is accepted. |
hidden |
error target | Removed while discarded input is being reported; authored visibility comes back afterwards. |
value |
hidden input target | Stores the final concatenated PIN code string synchronized on every input. |