Skip to content

A one-time code across native single-character inputs. Custom element

OTP Field
Copy this markup
<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

Styles and registration
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:

Explicit registration
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>

PartRequiredSelectorPurpose
cellYes[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.
separatorNo[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

PartRequiredSelectorPurpose
controlNo[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>

AttributeValuesDefaultDescription
nameany stringForm field name. The joined code submits as one entry through ElementInternals; the cells themselves carry no name.
lengthany numberHow many characters the code has. Defaults to the number of authored cells, and is what a partly filled field is measured against.
valueany stringThe 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.
requiredpresenceabsentPresent to block submission while the field is empty, with valueMissing.
disabledpresenceabsentPresent to disable the field. A field inside a disabled <fieldset> is disabled too, and submits nothing either way.

ui-field

AttributeValuesDefaultDescription
data-ui-layoutstacked · inlinestackedWhether the label sits above the control or beside it.
data-ui-densitycompact · normal · spaciousnormalGap between label, control, description, and error.

ui-input

AttributeValuesDefaultDescription
data-ui-sizesm · md · lgmdControl 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>

PropertyTypeNotes
namestringReflects the name attribute, documented above.
lengthstringReflects the length attribute, documented above.
defaultValuestringReflects the value attribute, documented above.
valuestringLive value. Assigning it does not rewrite the authored default and does not dispatch transition events.
requiredbooleanReflects the required attribute, documented above.
disabledbooleanReflects the disabled attribute, documented above.
EventDetailDescription
ui-before-changeCustomEvent<OtpFieldChangeDetail>Cancelable proposal dispatched before the code changes. Call preventDefault() to reject the transition and keep the current value.
ui-changeCustomEvent<OtpFieldChangeDetail>Dispatched after the code has changed. Bubbles and is composed.
ui-completeCustomEvent<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.

StateSourceHow to express it
invalidariaSet aria-invalid="true" on the control and point aria-describedby at the error part.
invalidnativeNative :invalid, or aria-invalid="true".
disablednativeNative 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 propertyControls
--ui-otp-cellWidth 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.

KeyAction
Printable charactersFill the focused cell and move focus to the next one.
BackspaceClear the focused cell, or step back and clear the previous one when it is already empty.
Arrow keysMove between cells.
Home / EndMove to the first or last cell.
PasteSpread 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.