# OTP Field

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

Reference: https://timeless.build/docs/components/otp-field/

> **Choosing between components.** Without JavaScript the cells are still usable native inputs, but only the enhanced field submits the code, because the joined value belongs to the host. That is the same bar [Select](/docs/components/select/) and [Combobox](/docs/components/combobox/) meet.

> **Markup you author.** Author every cell. `autocomplete="one-time-code"` goes on the first cell only, so the platform offers the SMS code once rather than per cell; every cell needs `maxlength="1"`, `inputmode="numeric"` for the numeric keyboard, and an accessible name naming its position. The cells carry no `name` — the joined code submits under the host `name`.

## Markup

```html
<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

```js
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:

```js
import { defineOtpFieldElement } from '@timelessui/components/define/ui-otp-field'
defineOtpFieldElement()
```

## Anatomy

Parts are authored in your own markup and identified by the selector below. 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 |
| `length` | `string` | Reflects the `length` attribute |
| `defaultValue` | `string` | Reflects the `value` attribute |
| `value` | `string` | Live value. Assigning it does not rewrite the authored default and does not dispatch transition events. |
| `required` | `boolean` | Reflects the `required` attribute |
| `disabled` | `boolean` | Reflects the `disabled` attribute |

| 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), global and set at the theme level: `--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 markup. 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.
