Primitives Forms
One-time code
One native input for a verification code, drawn as cells by the stylesheet. Paste, the autofill of a received code, validation and the form value stay the browser’s because the code is a single field. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
<input>,inputmode="numeric",autocomplete="one-time-code",maxlength,pattern,:user-invalid- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<input inputmode="numeric" autocomplete="one-time-code" maxlength>
On this page
Example
Enter the six digits sent to your phone.
<label for="otp-field-code">Verification code</label>
<input
id="otp-field-code"
name="code"
data-slean="otp-field"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
pattern="\d{6}"
required
aria-describedby="otp-field-code-hint"
/>
<p id="otp-field-code-hint">Enter the six digits sent to your phone.</p><script lang="ts">
// Nothing to import: the browser owns the field. The stylesheet is optional.
import '@svelte-lean/styles/otp-field.css';
let code = $state('');
</script>
<label for="otp-field-code">Verification code</label>
<!-- Braces start an expression in a .svelte attribute, so the pattern is a string. -->
<input
id="otp-field-code"
name="code"
data-slean="otp-field"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
pattern={'\\d{6}'}
required
aria-describedby="otp-field-code-hint"
bind:value={code}
/>
<p id="otp-field-code-hint">Enter the six digits sent to your phone.</p>Why this implementation exists
A code input built from one input per digit needs JavaScript on every keystroke to move focus, and more to rebuild what a single text field does on its own: a pasted or autofilled code has to be split across the boxes, Backspace and selection have to cross them, and a screen reader meets six unlabeled fields.
Here the code is one <input>. autocomplete="one-time-code" lets the browser offer a code it received, inputmode="numeric" brings up a digit keyboard, and maxlength with pattern constrain the value. The stylesheet draws the cells: a monospace font gives every character the same advance, letter-spacing spaces the characters evenly, and a gradient draws one mark under each position.
The browser owns
- typing, deleting, selecting and pasting in one field
- the numeric keyboard (inputmode) and the offered code (autocomplete="one-time-code")
- maxlength, pattern and required validation, :user-invalid after an edit
- form participation and the label association
Svelte Lean owns
- otp-field.css: the cells, drawn with a monospace font, letter-spacing and a gradient for 4, 6 or 8 characters
- the contract and the OtpFieldLength type
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@svelte-lean/styles/otp-field.css';Set maxlength to the length of the code and data-slean-length to the same number: 4, 6 or 8, and 6 when the attribute is absent. Match pattern to the characters you send, \d{6} for six digits.
In a .svelte file, braces in an attribute value start an expression, so write the pattern as a string: pattern={'\\d{6}'}.
The browser keeps a pasted code as pasted, up to maxlength: spaces and dashes stay and fail pattern. Send codes without separators, or handle the paste in the application.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <input data-slean="otp-field" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="\d{6}"> | – | yes | One input for the whole code, with a label. data-slean-length="4" or "8" when maxlength is 4 or 8; the default is 6. |
| cells | the input’s background | – | styles only | Drawn by the stylesheet, not elements: one mark per character position, accent-colored while the field has focus. |
Runtime profile
Tier 0: the field has no behavior module, and the Vite plugin maps otp-field to no module. The cells are CSS; typing, pasting and autofill run in the browser.
Accessibility contract
- One labelled text input: assistive technology announces one field with its label and hint, not one box per digit.
aria-describedbypoints at the hint, and at the error message when there is one.- The focus ring is the shared one; while the field has
:focus-visiblethe stylesheet also draws the cell marks in the accent color. :user-invalidmarks the field only after the user edited it, so an untouched required field is not red.
Keyboard
| Key | When | Result |
|---|---|---|
| characters | focus in the field | Typed at the caret; typing stops at maxlength (native) |
| Backspace/Delete | focus in the field | Removes a character; no focus moves between cells (native) |
| ArrowLeft/ArrowRight/Home/End | focus in the field | Moves the caret (native) |
| Enter | focus in the field | Submits the form through implicit submission (native) |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
inputmode | Baseline 2021 | The default keyboard |
autocomplete="one-time-code" | Safari on iOS and macOS offers a code received by SMS; elsewhere browser-dependent | A plain text input; the code is typed or pasted |
:user-invalid | Baseline 2023 | No invalid border after an edit |
Without JavaScript
Fully functional: typing, pasting, autofill, validation and submission need no script.
Server rendering
Static HTML. A code field has no initial value; the server renders the empty input.
Before hydration
Nothing is attached; the field works before and after hydration alike.
Styling
otp-field.css sets a monospace font and letter-spacing, sizes the content box to the width the characters take so the input never scrolls, and draws the marks with a gradient in the border token (the accent token while focused). The field is drawn left to right in every document direction, because the geometry starts at the first character. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-control-height-lg | 2.75rem | block-size |
--slean-space-2 | 0.5rem | block-size, background-position |
--slean-control-border | var(--slean-border-strong) | border, background-image |
--slean-radius-md | 0.625rem | border-radius |
--slean-control-bg | var(--slean-surface) | background-color |
--slean-fg | var(--slean-neutral-12) | color |
--slean-accent | oklch(54% 0.19 258) | caret-color, background-image |
--slean-font-mono | ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace | font-family |
--slean-text-lg | 1.125rem | font-size |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-fg-muted | var(--slean-neutral-11) | color |
--slean-control-border-hover | var(--slean-accent) | border-color |
--slean-control-border-focus | var(--slean-accent) | border-color |
--slean-control-ring | 0 0 0 3px color-mix(in oklch, var(--slean-accent) 22%, transparent) | box-shadow |
--slean-warning | oklch(76% 0.16 80) | border-color |
--slean-control-ring-warning | 0 0 0 3px color-mix(in oklch, var(--slean-warning) 28%, transparent) | box-shadow |
--slean-danger | oklch(55% 0.2 25) | border-color |
--slean-control-ring-danger | 0 0 0 3px color-mix(in oklch, var(--slean-danger) 22%, transparent) | box-shadow |
--slean-control-border-disabled | var(--slean-border) | border-color |
--slean-control-bg-disabled | var(--slean-muted) | background-color |
State selectors the stylesheet targets, all from the platform or ARIA: ::placeholder, :disabled, :focus-visible, :hover, :user-invalid, [aria-invalid="true"].
Variant attributes: data-slean-length (4, 8); data-status (warning, error).
Controlled integration
Submitting when the last digit arrives is the application’s decision; the field does not do it. With bind:value, an effect can submit the form once the value matches.
<script lang="ts">
let form: HTMLFormElement;
let code = $state('');
// The field never submits by itself. This application submits once six digits are in.
$effect(() => {
if (/^\d{6}$/.test(code)) form.requestSubmit();
});
</script>
<form method="post" action="/verify" bind:this={form}>
<label for="otp-field-code">Verification code</label>
<input id="otp-field-code" name="code" data-slean="otp-field" inputmode="numeric"
autocomplete="one-time-code" maxlength="6" pattern={'\\d{6}'} required bind:value={code} />
</form>Compatibility notes
inputmode is Baseline 2021; without it the default keyboard appears. Safari on iOS and macOS offers a code received by SMS for autocomplete="one-time-code"; elsewhere the suggestion depends on the browser and the keyboard. With a maxlength other than 4, 6 or 8 the stylesheet draws six cells and the input still works.
Examples
Lengths
data-slean-length draws four or eight cells and must equal maxlength.
The second field takes letters and digits: it has no inputmode, a pattern for both,
and spellcheck="false" so no spelling marks cross the cells.
<!-- data-slean-length matches maxlength: 4, 6 (the default) or 8. -->
<label for="otp-field-short">Four-digit code</label>
<input id="otp-field-short" name="short-code" data-slean="otp-field" data-slean-length="4"
inputmode="numeric" autocomplete="one-time-code" maxlength="4" pattern="\d{4}" />
<!-- Letters and digits: no inputmode, a pattern for both, no spelling marks. -->
<label for="otp-field-long">Eight-character code</label>
<input id="otp-field-long" name="long-code" data-slean="otp-field" data-slean-length="8"
autocomplete="one-time-code" maxlength="8" pattern="[A-Za-z0-9]{8}" spellcheck="false" />Testing
apps/playground/tests/primitives/inputs-overlays.spec.tsPlaywright, with page JavaScript enabled and disabled, and axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/otp-field/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/otp-field/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/otp-field.cssthe optional stylesheet