OTP Input
stimeo--otp
Accessible multi-cell passcode / PIN input supporting auto-advance, backspace retreat, and smart paste splitting.
The stimeo--otp controller streamlines the creation of highly usable and accessible multi-segment numeric passcode entry inputs popular in Multi-Factor Authentication (MFA). It supports keyboard roving and validation pattern checks, auto-advances focus on valid inputs, steps back and clears previous cells on Backspace, and splits pasted multi-digit text across segmented fields seamlessly. Dispatches stimeo--otp:complete once all fields are successfully filled.
Keyboard
| Key | Action |
|---|---|
| Backspace | If the current field is empty, moves focus to the previous field and clears its content. |
| ← | Moves focus to the previous input field. |
| → | Moves focus to the next input field. |
| Home | Moves focus to the first input field. |
| End | Moves focus to the last input field. |
<%# Markup for the otp (OTP PIN passcode) demo.
Provides auto-advance on entry in each field, Backspace to go back, paste
distribution, and value sync. %>
<div
class="otp-demo"
id="otp-passcode"
data-controller="stimeo--otp"
data-stimeo--otp-length-value="6"
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"
data-stimeo--otp-target="field"
data-action="
input->stimeo--otp#onInput
keydown->stimeo--otp#onKeydown
paste->stimeo--otp#onPaste
"
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>
<%# Placeholder for the completion celebration message. %>
<div id="otp-complete-message" class="otp-demo__message" style="display: none;">
<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>
</div>
/*
* Presentation-only styles for the otp demo.
* Whether a field is filled switches on data-filled (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);
}
.otp-demo__message {
margin-top: 1rem;
display: flex;
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__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 catches the complete event and shows a celebration message.
document.getElementById('otp-passcode').addEventListener('stimeo--otp:complete', function (e) {
const msg = document.getElementById('otp-complete-message');
const code = document.getElementById('otp-success-code');
if (msg && code) {
code.textContent = e.detail.value;
msg.style.display = 'flex';
}
});
// Hide the message when the value changes.
// Backspace clears values programmatically in the controller, so the native input
// event doesn't fire — subscribe to stimeo--otp:change instead.
document.getElementById('otp-passcode').addEventListener('stimeo--otp:change', function (e) {
const fields = Array.from(document.querySelectorAll('.otp-demo__field'));
const filled = fields.every((f) => f.value.length > 0);
if (!filled) {
const msg = document.getElementById('otp-complete-message');
if (msg) msg.style.display = 'none';
}
});
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, focus auto-advances between them. | 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 an invalid-input state. | data-stimeo--otp-target="error" |
Values
| Name | Description | Attribute |
|---|---|---|
length
|
Number of digits / fields in the code (default 6). | data-stimeo--otp-length-value |
pattern
|
Per-character validation regex (default [0-9]). |
data-stimeo--otp-pattern-value |
Actions
| Name | Description | Action |
|---|---|---|
onInput
|
Validates the typed character, marks the field filled, 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 |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched whenever the combined value changes, with that value in detail. | stimeo--otp:change |
complete
|
Dispatched when all fields are filled, with the full value in detail. | stimeo--otp:complete |
invalid
|
Dispatched on a rejected character/paste, with the pattern in detail. | stimeo--otp:invalid |
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. |
value |
hidden input target | Stores the final concatenated PIN code string synchronized on every input. |