registry:ui · ElementInternals
input-otp — six slots, one form value
Single-character inputs with the mechanics a raw stack lacks: typing overflows into the next slots, backspace steps back, paste distributes, focus entering from outside lands on the first empty slot. The group is not a single native control — exactly the jx-form-field bridge case: the joined code submits as ONE value; a partial code submits empty (never a partial lie).
$npx jixoai-ui add input-otp
https://ui.jixoai.com/r/input-otp.json
overview
Overview
N real inputs with the mechanics a raw stack lacks — and the ElementInternals bridge that makes them submit as ONE honest value. The per-slot model is the choice: real inputs are what one-time-code autofill targets, and backstep, arrow keys, and paste distribution come free from native input behavior — a single-hidden-input alternative would have to rebuild all three.
The a11y model is PER-SLOT: the slots are real <input>s inside a labelled role="group" — no single hidden master input, no
synthesized aria. The platform affordances stay native: the first slot carries autocomplete="one-time-code" (the SMS code lands on
it), inputmode follows the numeric prop for the right mobile keyboard, and aria-invalid + aria-describedby wire the error line. The mechanics
layered on top are focus management only: typing overflows into the next slots, a
pasted code distributes from the slot it landed in, backspace on an empty slot steps
back, and focus entering the set from outside lands on the first EMPTY slot
(measured live in the accessibility table).
Form semantics ride the jx-form-field bridge (the group is not one native control):
the joined code submits as ONE value under name — a 6-slot OTP submits "123456", never six fragments — and an INCOMPLETE
code submits the empty string (never a partial lie). required and form reset flow through the bridge
like every jixoai form control. The two-way slot/value sync is untracked on the slots
side: freshly typed characters can never be re-synced against a stale value.
The paint is typed tokens end to end — slot frame, ground, ink, caret, the
filled/complete/invalid borders — so a scoped .dark moves nothing on the surface
(typed-frozen; measured in the axes table) while the FOCUS ring and the
complete-state focus ink are the raw machines that do flip. Density is the one live
ladder: the slot box rides max(--jx-hit, --jx-line × 2) — measured 32/36/40/48px.
Kinship: input (the shell/label/error law), number-input (the stepped-entry sibling).
types
InputOtp variants
The default numeric six-slot code, an alpha code (numeric=false), a shorter code, and the error state.
input-otp · variants
code expired — resend
<script lang="ts">
import InputOtp from '@ui/input-otp.svelte';
</script>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-3 border border-border p-4">
<InputOtp label="numeric (6)" length={6} />
<span class="text-muted-foreground text-[12px]">digits only · inputmode numeric</span>
</div>
<div class="flex flex-col gap-3 border border-border p-4">
<InputOtp label="alpha (numeric={false})" length={6} numeric={false} />
<span class="text-muted-foreground text-[12px]">letters accepted · inputmode text</span>
</div>
<div class="flex flex-col gap-3 border border-border p-4">
<InputOtp label="short code (4)" length={4} />
<span class="text-muted-foreground text-[12px]">length clamps to 1…12</span>
</div>
<div class="flex flex-col gap-3 border border-border p-4">
<InputOtp label="error" length={6} value="123" error="code expired — resend" />
<span class="text-muted-foreground text-[12px]">dashed destructive border on every slot</span>
</div>
</div>usage
Usage
Name the field; the joined code submits as ONE value — a partial code submits empty, never a partial lie.
<script lang="ts">
import InputOtp from '@ui/input-otp.svelte';
</script>
<InputOtp name="otp" length={6} label="one-time code" bind:value />theming
Density and tokens
Slot size and rhythm derive from the density scope; resize the scope and the whole code field follows.
| Token | Default | Source |
|---|---|---|
| slot size | max(var(--jx-hit), calc(var(--jx-line) * 2)) | component |
| --jx-hit | 28 / 32 / 40 / 48px | density |
| --jx-line | 16 / 18 / 20 / 24px | density |
| --jx-gap | 8 / 8 / 12 / 16px | density |
| --jx-text | 11 / 12 / 13 / 15px | density |
api
API
Props spread onto every slot input; form submission (joined value, required, reset) rides the jx-form-field bridge. The density row rides the EXTRA lane (reference identity): it carries an axis NAME with a family-specific ambient-scope contract, so the universal fold would hide it — the carriers-bijection ruling, task 29.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| name | string | — | Form field name — the joined code submits under it. |
| length | number | 6 | Slot count, clamped to 1…12. |
value bind | string | '' | The joined code; bind:value for controlled use. |
| numeric | boolean | true | Digits-only slots; false accepts letters for alpha codes. |
| label | string | — | Reads above the slots; also the group aria-label. |
| error | string | — | Error text: dashes every slot border (destructive) + the message line. |
| disabled | boolean | false | Dims and freezes the whole slot set. |
| density | '2xs' | 'xs' | 'sm' | 'default' | 'lg' | ambient scope | Explicit override of the ambient density scope; no opinion stamps nothing and the ambient css scope channel flows. These are the rung ids you pass; the axes table carries the alias ladder they resolve to, with the measured geometry (slot box 32/36/40/48px). |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on input-otp
SEVEN of the eight axes stamp-and-supply or go unused — density is the one CONSUMED ladder (the slot geometry IS the contract: measured 32/36/40/48px boxes, 11/12/13/15px voices, 8/8/12/16px rhythm). Size is the §11 echo with the density-governed slot voice as its nothing-follows proof (the pre-29 'CONSUMES size and color' text is falsified here). The theme split is two-voice: typed slot frames frozen under scoped .dark, raw focus machines that flip. The native input carries NO size-like attribute collision (the §1 native-wrapper rule).
| Property | Type | Default | Description |
|---|---|---|---|
| density | 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings) | 'auto' | CONSUMED — the slot geometry IS the density contract: the slot box rides max(--jx-hit, --jx-line × 2) — measured 32 / 36 / 40 / 48px across xs/sm/default/lg (the line×2 leg WINS at xs/sm, where 2 lines outgrow the hit); the cell voice rides --jx-text 11/12/13/15px; the slot rhythm --jx-gap 8/8/12/16px; the label --jx-text-secondary. The ambient attr stays absent under DensityDemo scope boxes (the two-channel finding — tokens cascade from the ancestor scope). Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | THE §11 ECHO — the stamp lands verbatim (measured root inline: --jx-size-effective: 14px; font-size: var(--jx-size-effective, 1rem); computed 14px / 18px at the demo lanes) and the slots follow NOTHING of it: the slot voice is --jx-text (density-governed) and the slot box is the hit/line lane. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — stamps the carriers; zero readers in the family css (grep receipt). The slot corner is the family radius law: the typed --jx-radius token. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-radius-effective; zero readers (grep receipt). The slot corner rides the typed --jx-radius token directly — the axis carrier finds no reader. Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY — stamps --jx-color-effective; zero readers (grep receipt). The voices are typed tokens: slot ink/paper --jx-foreground/--jx-background, caret --jx-primary, complete border --jx-primary, invalid --jx-destructive. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | THE BRIDGE + A TWO-VOICE SPLIT — the .dark class lands on the family root and the voices answer by emission form. TYPED POLE (frozen at :root): the slot frame and ground — measured inside a scoped .dark the border stays oklch(0 0 0) and the ground oklch(1 0 0): a code field keeps its light paper in a dark island. RAW MACHINES (flip): the focus ring var(--ring) (measured flipping to the dark ring oklch(0.7044 0.1872 …)) and the complete-state focus ink var(--primary). The code itself is user data — the value never re-themes. system/auto ride tree inheritance. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-elevation-effective; zero readers (grep receipt: zero shadow declarations — a flat code field by design). Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-motion-effective; zero readers (grep receipt: zero transition declarations in the family css and atoms — the complete-state border swap is instant, no ring animation to kill). Number unit: coefficient. |
Receipts: the density ladder, the size echo, and the typed-frozen split were measured on this page's served DOM (probe, task 29); the supply-only rows carry grep receipts over ui/input-otp/. The universal demo folds in below, with a query() seat: the size lane rides the md viewport key (48rem).
<script lang="ts">
import InputOtp from '@ui/input-otp.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (14px) applies; at 48rem+ the md case (18px)
wins — the §11 echo the root stamps (the slots follow the density
kernels, not this stamp) -->
<InputOtp label="responsive code" length={6} size={query({ md: 18 }, 14)} />input-otp · query()
Media keys are min-width: below 48rem the base applies — the root stamp reads 14px; at 48rem and wider the md case wins — 18px. The number lane goes bare (results infer); string lanes take both generics. Resize across 48rem.
<script lang="ts">
import InputOtp from '@ui/input-otp.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (14px) applies; at 48rem+ the md case (18px)
wins — the §11 echo the root stamps (the slots follow the density
kernels, not this stamp) -->
<InputOtp label="responsive code" length={6} size={query({ md: 18 }, 14)} />input-otp · universal props
<InputOtp label="px number" size={14} density="small" />
<InputOtp label="named steps" size="large" radius="medium" />a11y
Accessibility
The slots are real inputs in a labelled group; the typing mechanics (advance, backstep, paste distribution) are the only layer on top.
Keyboard
| Key | Action |
|---|---|
| Tab | Focus entering from outside lands on the first EMPTY slot |
| any digit / char | Fills the slot and advances; overflow distributes into the next slots |
| Backspace | On an empty slot, steps back and clears the previous one |
| ← / → | Walks between slots, selecting the landed value |
| paste | A whole code pasted into any slot distributes across the set |
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | group | On the container, labelled by the label prop (aria-label fallback: "one-time code") |
| autocomplete | "one-time-code" | On the first slot — the platform offers the SMS code |
| inputmode | "numeric" / "text" | Follows the numeric prop for the right mobile keyboard |
| aria-invalid | 'true' | On every slot when the error prop is provided |
| aria-describedby | {id}-error | Points at the "! message" validation line |