OTP Field
View as MarkdownA one-time code across native single-character inputs. Custom element
<div class="ui-field"> <span class="ui-label" id="signin-code-label">Verification code</span> <ui-otp-field id="signin-code" name="code" length="6" aria-labelledby="signin-code-label" aria-describedby="signin-code-description"> <input class="ui-input" data-ui-part="cell" id="signin-code-1" type="text" inputmode="numeric" maxlength="1" autocapitalize="off" autocorrect="off" spellcheck="false" autocomplete="one-time-code" aria-label="Digit 1 of 6" value=""> <input class="ui-input" data-ui-part="cell" id="signin-code-2" type="text" inputmode="numeric" maxlength="1" autocapitalize="off" autocorrect="off" spellcheck="false" autocomplete="off" aria-label="Digit 2 of 6" value=""> <input class="ui-input" data-ui-part="cell" id="signin-code-3" type="text" inputmode="numeric" maxlength="1" autocapitalize="off" autocorrect="off" spellcheck="false" autocomplete="off" aria-label="Digit 3 of 6" value=""> <span data-ui-part="separator" aria-hidden="true">–</span> <input class="ui-input" data-ui-part="cell" id="signin-code-4" type="text" inputmode="numeric" maxlength="1" autocapitalize="off" autocorrect="off" spellcheck="false" autocomplete="off" aria-label="Digit 4 of 6" value=""> <input class="ui-input" data-ui-part="cell" id="signin-code-5" type="text" inputmode="numeric" maxlength="1" autocapitalize="off" autocorrect="off" spellcheck="false" autocomplete="off" aria-label="Digit 5 of 6" value=""> <input class="ui-input" data-ui-part="cell" id="signin-code-6" type="text" inputmode="numeric" maxlength="1" autocapitalize="off" autocorrect="off" spellcheck="false" autocomplete="off" aria-label="Digit 6 of 6" value=""> </ui-otp-field> <p class="ui-description" id="signin-code-description">Paste the whole code — it spreads across the cells.</p></div>Install
import '@timelessui/components/css/tokens.css'import '@timelessui/components/css/core/forms.css'import '@timelessui/components/css/core/otp-field.css'import '@timelessui/components/css/themes/atmosphere/tokens.css'import '@timelessui/components/css/themes/atmosphere/forms.css'import '@timelessui/components/css/themes/atmosphere/otp-field.css'import '@timelessui/components/register/ui-otp-field'A register/ import is a side effect: it defines the element as the module evaluates, so it has to run in code the browser loads. To register explicitly instead — to control the timing, or to define into another window — call the function from the matching define/ entry point, which registers nothing on its own:
import { defineOtpFieldElement } from '@timelessui/components/define/ui-otp-field'defineOtpFieldElement()Anatomy
Parts are authored in your own markup and identified by the selector below, so your CSS can target the same anatomy without loading Timeless styles. Required parts must be present for the component to work. Private data-ui-internal-* hooks are written by the runtime and must never be authored.
<ui-otp-field>
| Part | Required | Selector | Purpose |
|---|---|---|---|
cell | Yes | [data-ui-part~='cell'] | One native input holding one character. Author maxlength="1", inputmode="numeric", an accessible name naming its position, and autocomplete="one-time-code" on the first cell only. Give it class="ui-input" to pick up the shared control styling. |
separator | No | [data-ui-part~='separator'] | Decorative mark between groups of cells, as in 123-456. Hide it from assistive technology with aria-hidden="true". |
ui-field
| Part | Required | Selector | Purpose |
|---|---|---|---|
control | No | [data-ui-part~='control'] | Wrapper around the native control when one is needed. |
Attributes
Every value below is implemented by the stylesheets this component ships. Boolean attributes are presence-based: author the attribute with no value, or omit it.
<ui-otp-field>
| Attribute | Values | Default | Description |
|---|---|---|---|
name | any string | — | Form field name. The joined code submits as one entry through ElementInternals; the cells themselves carry no name. |
length | any number | — | How many characters the code has. Defaults to the number of authored cells, and is what a partly filled field is measured against. |
value | any string | — | The code on load and after a form reset. Assign the value property for live changes; once the user types, the attribute stops applying, the way it does on a native input. |
required | presence | absent | Present to block submission while the field is empty, with valueMissing. |
disabled | presence | absent | Present to disable the field. A field inside a disabled <fieldset> is disabled too, and submits nothing either way. |
ui-field
| Attribute | Values | Default | Description |
|---|---|---|---|
data-ui-layout | stacked · inline | stacked | Whether the label sits above the control or beside it. |
data-ui-density | compact · normal · spacious | normal | Gap between label, control, description, and error. |
ui-input
| Attribute | Values | Default | Description |
|---|---|---|---|
data-ui-size | sm · md · lg | md | Control height, padding, and font size. |
Element API
Attributes above are the authoring surface. These are the DOM properties and events the registered element adds once it upgrades.
<ui-otp-field>
| Property | Type | Notes |
|---|---|---|
name | string | Reflects the name attribute, documented above. |
length | string | Reflects the length attribute, documented above. |
defaultValue | string | Reflects the value attribute, documented above. |
value | string | Live value. Assigning it does not rewrite the authored default and does not dispatch transition events. |
required | boolean | Reflects the required attribute, documented above. |
disabled | boolean | Reflects the disabled attribute, documented above. |
| Event | Detail | Description |
|---|---|---|
ui-before-change | CustomEvent<OtpFieldChangeDetail> | Cancelable proposal dispatched before the code changes. Call preventDefault() to reject the transition and keep the current value. |
ui-change | CustomEvent<OtpFieldChangeDetail> | Dispatched after the code has changed. Bubbles and is composed. |
ui-complete | CustomEvent<OtpFieldCompleteDetail> | Dispatched once every character the field expects has been entered, which is where an auto-submit belongs. |
State
Native attributes, ARIA, and platform pseudo-classes are authoritative. Style state through the selectors below rather than adding your own state classes.
| State | Source | How to express it |
|---|---|---|
invalid | aria | Set aria-invalid="true" on the control and point aria-describedby at the error part. |
invalid | native | Native :invalid, or aria-invalid="true". |
disabled | native | Native disabled. |
Styling
Root identity: ui-otp-field, ui-field, ui-label, ui-input, ui-description. Required stylesheets: tokens.css, core/forms.css, core/otp-field.css, themes/atmosphere/tokens.css, themes/atmosphere/forms.css, themes/atmosphere/otp-field.css.
Set these custom properties to restyle the component:
| Custom property | Controls |
|---|---|
--ui-otp-cell | Width of one cell. |
Design tokens this component reads (26)
These are global Atmosphere tokens. Change them once at the theme level rather than per component.
--ui-accent · --ui-bg-control · --ui-bg-control-muted · --ui-bg-surface · --ui-bg-surface-subtle · --ui-danger · --ui-danger-soft · --ui-disabled-opacity · --ui-duration-fast · --ui-ease-standard · --ui-fg · --ui-fg-muted · --ui-fg-subtle · --ui-focus · --ui-line · --ui-line-strong · --ui-radius-md · --ui-radius-pill · --ui-radius-sm · --ui-shadow-control · --ui-shadow-inset · --ui-space-1 · --ui-space-2 · --ui-space-3 · --ui-space-4 · --ui-space-5
Accessibility
Keep the native elements, roles, and relationships shown in the example. Timeless adds only the state and keyboard coordination the platform does not already provide, and it never supplies your accessible names — those depend on your content.
The ARIA Authoring Practices Guide has no pattern for One-time code. The contract below is a composition of behavior the platform already defines, not ARIA invented to fill the gap.
| Key | Action |
|---|---|
| Printable characters | Fill the focused cell and move focus to the next one. |
| Backspace | Clear the focused cell, or step back and clear the previous one when it is already empty. |
| Arrow keys | Move between cells. |
| Home / End | Move to the first or last cell. |
| Paste | Spread the pasted code across the cells from the focused one onward. |
There is no APG pattern for a one-time-code field, so the contract is a composition of things the platform already defines rather than invented ARIA: a named role="group" over native inputs, each independently tabbable and separately labelled by position. No roving tabindex is written, because every cell is a real tab stop. Autofill, the numeric keyboard, and paste come from the inputs themselves.
Before JavaScript runs
The markup above is complete and usable on its own: native controls submit, labels associate, and authored ARIA is already correct. Registration adds state synchronisation, focus management, and keyboard coordination on top of it.
More examples
Open OTP Field in StoryLite for variants, controls, and manual testing.