jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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).

install — input-otp

$npx jixoai-ui add input-otp

https://ui.jixoai.com/r/input-otp.json

paste distributes backspace steps back one-time-code autocomplete bridge single value

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).

input-otp

Type, or paste a whole code into the first slot — it distributes. The joined value surfaces in the echo footer; the frame only turns brand-colored when COMPLETE.

input-otp-usage.svelte svelte
<InputOtp name="otp" length={6} label="one-time code" value="" />

types

InputOtp variants

The default numeric six-slot code, an alpha code (numeric=false), a shorter code, and the error state.

input-otp · variants

digits only · inputmode numeric
letters accepted · inputmode text
length clamps to 1…12

code expired — resend

dashed destructive border on every slot
input-otp-types-demo.svelte svelte
<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.

InputOtp usage
<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.

xs
sm
default
lg
TokenDefaultSource
slot sizemax(var(--jx-hit), calc(var(--jx-line) * 2))component
--jx-hit28 / 32 / 40 / 48pxdensity
--jx-line16 / 18 / 20 / 24pxdensity
--jx-gap8 / 8 / 12 / 16pxdensity
--jx-text11 / 12 / 13 / 15pxdensity

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

PropertyTypeDefaultDescription
name string—Form field name — the joined code submits under it.
length number6Slot count, clamped to 1…12.
value bindstring''The joined code; bind:value for controlled use.
numeric booleantrueDigits-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 booleanfalseDims and freezes the whole slot set.
density '2xs' | 'xs' | 'sm' | 'default' | 'lg'ambient scopeExplicit 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

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity 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).

PropertyTypeDefaultDescription
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).

one real query() case
<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.

input-otp-query-demo.svelte svelte
<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

input-otp-universal.svelte svelte
<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

KeyAction
TabFocus entering from outside lands on the first EMPTY slot
any digit / charFills the slot and advances; overflow distributes into the next slots
BackspaceOn an empty slot, steps back and clears the previous one
← / →Walks between slots, selecting the landed value
pasteA whole code pasted into any slot distributes across the set

ARIA

AttributeValueDescription
rolegroupOn 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}-errorPoints at the "! message" validation line
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h