Components
OTP
A one-time password field with character slots over one real input.
A <ui-otp> wraps a real <input class="ui-input ui-otp-input"> carrying standard OTP attributes: autocomplete="one-time-code", inputmode="numeric", pattern, and maxlength. It stamps an aria-hidden rail of character cells behind the input and stretches the input invisibly over the rail. The input remains the source of truth, so SMS autofill, clipboard paste, IME composition, form submission, and :user-invalid styling work natively while the visual cells mirror text entry. Without JavaScript, the element renders as a standard text input.
The code length comes from maxlength. Origin-bound SMS codes (@example.com #123456) autofill directly into the field.
Default
Loading components…
<form class="ui-field" style="max-inline-size: max-content"> <label data-slot="field-label" for="f-otp">Verification code</label> <ui-otp data-otp-groups="3-3"> <input class="ui-input ui-otp-input" id="f-otp" name="otp" type="text" autocomplete="one-time-code" inputmode="numeric" pattern="\d{6}" maxlength="6" required spellcheck="false" autocapitalize="off" /> </ui-otp> <div data-slot="field-description"> <span data-slot="field-hint">Enter the 6-digit code we sent you.</span> <span data-slot="field-error">Enter all 6 digits.</span> </div></form>// primitives/otp/otp.js"use strict";/** * @fileoverview `<ui-otp>`: a one-time password field with character slots. * @description Light-DOM custom element that augments a single * `<input class="ui-input ui-otp-input">` (the standard OTP shape with * `autocomplete="one-time-code"`, `inputmode`, `pattern`, and `maxlength`) * with a presentational slot rail. The input stays the source of truth: * autofill, paste, IME, undo, form submission, and `:user-invalid` are all * native. The script stamps an `aria-hidden` rail of character cells behind * the input and stretches the input invisibly over it; each cell mirrors one * character and the caret position. Without JavaScript the markup is a plain, * fully functional OTP input. * * The code length comes from the input `maxlength`. There is no * `data-otp-length`. * * Attributes on `<ui-otp>`: * - `data-otp-groups`: separator layout, e.g. `"3-3"` or `"2-2-2"`. The * groups must sum to `maxlength`; otherwise one ungrouped run renders. * - `data-otp-type`: sanitization charset: `numeric` (default), `alpha`, * `alphanumeric`, `none`. Keep it in agreement with the input `pattern` * and `inputmode`, which carry the no-JS validation. * - `data-otp-mask`: render `•` in the cells instead of characters. * - `data-otp-auto-submit`: request the owning form submission once when * the code is complete. * * Stamped state: cells are `[data-slot~="otp-slot"]` with `data-filled` / * `data-active`; separators are `[data-slot~="otp-separator"]`; the root * gains `data-otp-ready` when enhanced and `data-otp-complete` when full. */import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";import { effect, state } from "../../base/signals.js";// --- Pure derivations (exported for unit tests only) ---/** * @description Parses `data-otp-groups` ("3-3") into group sizes. Groups that * don't sum to the code length fall back to one ungrouped run. * * @param raw - The attribute value, or null. * @param length - The code length. * @returns Group sizes summing to `length`. */function parseGroups(raw, length) { if (!raw) return [length]; const groups = raw .split("-") .map((part) => Number.parseInt(part, 10)) .filter((size) => Number.isFinite(size) && size > 0); const total = groups.reduce((sum, size) => sum + size, 0); return total === length && groups.length > 0 ? groups : [length];}/** * @description Filters raw input text to the allowed charset and clamps it to * the code length. Whitespace never survives (pasted codes often carry it). * * @param raw - The input's current value. * @param type - The allowed charset. * @param length - The code length. * @returns The sanitized value. */function sanitizeOtp(raw, type, length) { let allowed; switch (type) { case "numeric": allowed = /[^0-9]/g; break; case "alpha": allowed = /[^a-zA-Z]/g; break; case "alphanumeric": allowed = /[^a-zA-Z0-9]/g; break; case "none": allowed = /\s/g; break; } return raw.replace(allowed, "").slice(0, length);}/** * @description Derives every cell's character and state from the input's * value and caret. The active cell tracks the caret; with the code full, the * last cell stays active. * * @param value - The sanitized value. * @param length - The code length. * @param caret - The input's caret position. * @param focused - Whether the input has focus. * @param mask - Whether to obscure characters. * @returns One state per cell. */function resolveSlots(value, length, caret, focused, mask) { const activeIndex = focused ? Math.min(caret, length - 1) : -1; return Array.from({ length }, (_, index) => { const char = value.charAt(index); return { char: char === "" ? "" : mask ? "•" : char, filled: char !== "", active: index === activeIndex, }; });}/** * @description Whether the code is complete. * * @param value - The sanitized value. * @param length - The code length. * @returns True when every cell is filled. */function isComplete(value, length) { return length > 0 && value.length === length;}// --- Element ---class UiOtp extends ZazzElement { #rail = null; #lastSubmitted = ""; setup(signal) { const input = this.querySelector("input"); if (!(input instanceof HTMLInputElement)) return; const length = input.maxLength > 0 ? input.maxLength : 6; const type = (this.getAttribute("data-otp-type") ?? "numeric"); const mask = this.hasAttribute("data-otp-mask"); const groups = parseGroups(this.getAttribute("data-otp-groups"), length); const rail = this.#stampRail(groups); this.setAttribute("data-otp-ready", ""); const value = state(sanitizeOtp(input.value, type, length)); const caret = state(input.selectionStart ?? 0); const focused = state(document.activeElement === input); // Input adapters: sanitize in place, then mirror value and caret input.addEventListener("input", () => { const clean = sanitizeOtp(input.value, type, length); if (input.value !== clean) input.value = clean; value.set(clean); caret.set(input.selectionStart ?? clean.length); }, { signal }); document.addEventListener("selectionchange", () => { if (document.activeElement !== input) return; caret.set(input.selectionStart ?? 0); }, { signal }); input.addEventListener("focus", () => focused.set(true), { signal }); input.addEventListener("blur", () => focused.set(false), { signal }); // Clicking a cell moves the caret to it rail.addEventListener("pointerdown", (event) => { const cell = event.target instanceof Element ? event.target.closest("[data-otp-index]") : null; event.preventDefault(); input.focus(); const index = cell ? Number(cell.getAttribute("data-otp-index")) : length; const position = Math.min(index, input.value.length); input.setSelectionRange(position, position); caret.set(position); }, { signal }); // Output adapter: one effect writes all derived attributes together effect(() => { const slots = resolveSlots(value.get(), length, caret.get(), focused.get(), mask); const cells = rail.querySelectorAll("[data-otp-index]"); slots.forEach((slot, index) => { const cell = cells[index]; if (!(cell instanceof HTMLElement)) return; cell.textContent = slot.char; if (slot.filled) cell.setAttribute("data-filled", ""); else cell.removeAttribute("data-filled"); if (slot.active) cell.setAttribute("data-active", ""); else cell.removeAttribute("data-active"); }); const complete = isComplete(value.get(), length); if (complete) this.setAttribute("data-otp-complete", ""); else this.removeAttribute("data-otp-complete"); if (complete && this.hasAttribute("data-otp-auto-submit")) { // Once per distinct complete value, so corrections can resubmit if (this.#lastSubmitted !== value.get()) { this.#lastSubmitted = value.get(); input.form?.requestSubmit(); } } }, { signal }); } teardown() { this.#rail?.remove(); this.#rail = null; this.removeAttribute("data-otp-ready"); this.removeAttribute("data-otp-complete"); } /** * @description Builds the aria-hidden cell rail (with separators between * groups) and inserts it after the input. * * @param groups - Group sizes, summing to the code length. * @returns The rail element. * @private */ #stampRail(groups) { const rail = document.createElement("div"); rail.setAttribute("data-slot", "otp-rail"); rail.setAttribute("aria-hidden", "true"); let index = 0; groups.forEach((size, groupIndex) => { if (groupIndex > 0) { const separator = document.createElement("span"); separator.setAttribute("data-slot", "otp-separator"); separator.setAttribute("aria-role", "presentation"); separator.textContent = "–"; rail.append(separator); } for (let i = 0; i < size; i++) { const cell = document.createElement("span"); cell.setAttribute("data-slot", "otp-slot"); cell.setAttribute("data-otp-index", String(index++)); rail.append(cell); } }); this.append(rail); this.#rail = rail; return rail; }}defineZazzElement("ui-otp", UiOtp);export { UiOtp, parseGroups, sanitizeOtp, resolveSlots, isComplete };/** * otp.css: OTP (ui-otp wrapping .ui-otp-input on one real <input>) * * @layer variables, components * @requires layers.css, _variables.css, fields.css, input.css * @uses Invisible-overlay input: the real input stretches over the * stamped cell rail with transparent text/caret so autofill, * paste, and focus stay native; never display:none * @uses :focus-within: active-cell ring only while the input has focus * @tokens --ui-otp-* (@layer variables; aliases --ui-field-*) * @see otp.ts: stamps the rail and mirrors value/caret into the cells */@layer variables { :root { --ui-otp-slot-inline-size: var(--ui-field-height); --ui-otp-slot-block-size: var(--ui-field-height); --ui-otp-slot-background: var(--ui-field-background); --ui-otp-slot-border: 1px solid var(--ui-field-border); --ui-otp-slot-radius: var(--ui-field-radius); --ui-otp-slot-font-size: var(--font-size-md); --ui-otp-gap: var(--step-1_5); --ui-otp-separator-color: var(--muted-foreground); --ui-otp-caret-color: var(--ui-field-border--focus); }}@layer zazz.components { /* =========================================================================== OTP: one real input, presentational cells - Before enhancement (.ui-otp-input alone): a centered code field. - After ([data-otp-ready]): the rail is the visual and the input becomes an invisible overlay (focusable, autofillable, never display:none). =========================================================================== */ :where(ui-otp) { position: relative; display: inline-grid; } /* No-JS baseline: a plain code field in the field family */ .ui-otp-input { text-align: center; letter-spacing: 0.5ch; font-family: var(--font-family-mono); font-size: var(--ui-otp-slot-font-size); } /* Enhanced: the input stretches invisibly over the rail. Opacity stays just above zero and the element keeps its size so taps, focus, and browser autofill UI keep landing on a real, visible-to-the-UA control. */ :where(ui-otp[data-otp-ready]) .ui-otp-input { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; opacity: 0.02; color: transparent; caret-color: transparent; letter-spacing: normal; background: none; border: none; box-shadow: none; outline: none; /* Guards iOS focus zoom */ font-size: max(16px, 1em); } [data-slot~="otp-rail"] { display: flex; align-items: center; gap: var(--ui-otp-gap); } [data-slot~="otp-slot"] { display: grid; place-items: center; inline-size: var(--ui-otp-slot-inline-size); block-size: var(--ui-otp-slot-block-size); font-family: var(--font-family-mono); font-size: var(--ui-otp-slot-font-size); color: var(--ui-field-foreground); background-color: var(--ui-otp-slot-background); border: var(--ui-otp-slot-border); border-radius: var(--ui-otp-slot-radius); transition: var(--default-transition); } /* Active cell: ring + focus border, only while the input has focus */ :where(ui-otp:focus-within) [data-slot~="otp-slot"][data-active] { border-color: var(--ui-field-border--focus); background-color: var(--ui-field-background--focus); box-shadow: 0 0 0 var(--ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--ring-offset-width) + var(--ring-width)) color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent); } /* Fake caret in the active empty cell */ :where(ui-otp:focus-within) [data-slot~="otp-slot"][data-active]:empty::after { content: ""; inline-size: 1px; block-size: 1.2em; background-color: var(--ui-otp-caret-color); } @media (prefers-reduced-motion: no-preference) { :where(ui-otp:focus-within) [data-slot~="otp-slot"][data-active]:empty::after { animation: ui-otp-caret-blink 1.1s steps(2, start) infinite; } } @keyframes ui-otp-caret-blink { to { visibility: hidden; } } [data-slot~="otp-separator"] { color: var(--ui-otp-separator-color); user-select: none; }}API
| Attribute | Target | Values |
|---|---|---|
maxlength | .ui-otp-input | The code length (native) |
data-otp-groups | ui-otp | Separator layout, e.g. 3-3, 2-2-2 (must sum to maxlength) |
data-otp-type | ui-otp | numeric (default), alpha, alphanumeric, none |
data-otp-mask | ui-otp | Render • instead of characters |
data-otp-auto-submit | ui-otp | Submit the owning form once when complete |
data-otp-complete | ui-otp (stamped) | Present when every cell is filled |