API
Otp
One-time passcode entry over a single native input.
Installation
pnpm dlx zazz-ui add otpnpx zazz-ui add otpyarn dlx zazz-ui add otpbunx zazz-ui add otpAlso adds fields, input, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.
Examples
<form data-ui="field" style="--max-w: max-content">
<label data-field-slot="label" for="f-otp">Verification code</label>
<ui-otp data-otp-groups="3-3">
<input
data-ui="input"
data-otp-slot="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-field-slot="description">
<span data-field-slot="hint">Enter the 6-digit code we sent you.</span>
<span data-field-slot="error">Enter all 6 digits.</span>
</div>
</form>/**
* otp.css: OTP (ui-otp wrapping :where(ui-otp, [data-ui~="otp"]) [data-otp-slot~="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-cell-w: var(--ui-field-h);
--ui-otp-cell-h: var(--ui-field-h);
--ui-otp-cell-bg: var(--ui-field-bg);
--ui-otp-cell-border-width: var(--ui-field-border-width);
--ui-otp-cell-border-style: var(--ui-field-border-style);
--ui-otp-cell-border-color: var(--ui-field-border-color);
--ui-otp-cell-rounded: var(--ui-field-rounded);
--ui-otp-cell-font-size: var(--font-size-md);
--ui-otp-gap: calc(var(--spacing) * 1.5);
--ui-otp-separator-text: var(--color-muted-foreground);
--ui-otp-caret-color: var(--ui-field-border-color--focus-within);
}
}
@layer ui.components {
/* ===========================================================================
OTP: one real input, presentational cells
- Before enhancement (:where(ui-otp, [data-ui~="otp"]) [data-otp-slot~="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, [data-ui~="otp"]) {
position: relative;
display: inline-grid;
}
:where(ui-otp, [data-ui~="otp"]) [data-otp-slot~="input"] {
text-align: center;
letter-spacing: 0.5ch;
font-family: var(--font-family-mono);
font-size: var(--ui-otp-cell-font-size);
}
/* Opacity stays just above zero and the input keeps its size so taps, focus,
and autofill UI still target a real control. */
:where(ui-otp[data-otp-ready], [data-ui~="otp"][data-otp-ready]) [data-otp-slot~="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;
/* prevents iOS focus zoom */
font-size: max(16px, 1em);
}
:where(ui-otp, [data-ui~="otp"]) [data-otp-slot~="rail"] {
display: flex;
align-items: center;
gap: var(--ui-otp-gap);
}
:where(ui-otp, [data-ui~="otp"]) [data-otp-slot~="cell"] {
display: grid;
place-items: center;
inline-size: var(--ui-otp-cell-w);
block-size: var(--ui-otp-cell-h);
font-family: var(--font-family-mono);
font-size: var(--ui-otp-cell-font-size);
color: var(--ui-field-text);
background-color: var(--ui-otp-cell-bg);
border: var(--ui-otp-cell-border-width) var(--ui-otp-cell-border-style)
var(--ui-otp-cell-border-color);
border-radius: var(--ui-otp-cell-rounded);
transition: var(--default-transition);
}
:where(ui-otp:focus-within, [data-ui~="otp"]:focus-within)
[data-otp-slot~="cell"][data-otp-state~="active"] {
border-color: var(--ui-field-border-color--focus-within);
background-color: var(--ui-field-bg--focus-within);
box-shadow:
0 0 0 var(--ring-offset-width) var(--ring-offset-shadow-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);
}
:where(ui-otp:focus-within, [data-ui~="otp"]:focus-within)
[data-otp-slot~="cell"][data-otp-state~="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-ui~="otp"]:focus-within)
[data-otp-slot~="cell"][data-otp-state~="active"]:empty::after {
animation: ui-otp-caret-blink 1.1s steps(2, start) infinite;
}
}
@keyframes ui-otp-caret-blink {
to {
visibility: hidden;
}
}
:where(ui-otp, [data-ui~="otp"]) [data-otp-slot~="separator"] {
color: var(--ui-otp-separator-text);
user-select: none;
}
}// 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 data-ui="input" data-otp-slot="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`.
*
* 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-otp-slot~="cell"]` with a `data-otp-state`
* token list (`filled`, `active`); separators are `[data-otp-slot~="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";
/**
* @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;
}
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 });
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;
const state = [slot.filled && "filled", slot.active && "active"]
.filter(Boolean)
.join(" ");
if (state)
cell.setAttribute("data-otp-state", state);
else
cell.removeAttribute("data-otp-state");
});
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-otp-slot", "rail");
rail.setAttribute("aria-hidden", "true");
let index = 0;
groups.forEach((size, groupIndex) => {
if (groupIndex > 0) {
const separator = document.createElement("span");
separator.setAttribute("data-otp-slot", "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-otp-slot", "cell");
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 };Behavior
primitives/otp/otp.js
<ui-otp>: a one-time password field with character slots. Light-DOM custom element that augments a single <input data-ui="input" data-otp-slot="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.
Attributes on <ui-otp>:
data-otp-groups: separator layout, e.g."3-3"or"2-2-2". The groups must sum tomaxlength; otherwise one ungrouped run renders.data-otp-type: sanitization charset:numeric(default),alpha,alphanumeric,none. Keep it in agreement with the inputpatternandinputmode, 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-otp-slot~="cell"] with a data-otp-state token list (filled, active); separators are [data-otp-slot~="separator"]; the root gains data-otp-ready when enhanced and data-otp-complete when full.
Attributes
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="otp" | Identity | free value | Identity token in data-ui. |
<ui-otp> | Tag form | free value | Tag form; styled and, when scripted, registered under this name. |
data-otp-slot | Slot | cell input rail separator | Slot: a part inside the primitive. |
data-otp-state | State | active | State the kit's script writes; read it in selectors, never author it. |
data-otp-auto-submit | Config | free value | Configuration the kit's script reads. |
data-otp-complete | Config | free value | Configuration the kit's script reads. |
data-otp-groups | Config | free value | Configuration the kit's script reads. |
data-otp-index | Config | free value | Configuration the kit's script reads. |
data-otp-mask | Config | free value | Configuration the kit's script reads. |
data-otp-ready | Config | free value | Configuration the kit's script reads. |
data-otp-type | Config | free value | Configuration the kit's script reads. |
Hooks
Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.
| Hook | Default | State |
|---|---|---|
--ui-otp-caret-color | var(--ui-field-border-color--focus-within) | — |
--ui-otp-cell-bg | var(--ui-field-bg) | — |
--ui-otp-cell-border-color | var(--ui-field-border-color) | — |
--ui-otp-cell-border-style | var(--ui-field-border-style) | — |
--ui-otp-cell-border-width | var(--ui-field-border-width) | — |
--ui-otp-cell-font-size | var(--font-size-md) | — |
--ui-otp-cell-h | var(--ui-field-h) | — |
--ui-otp-cell-rounded | var(--ui-field-rounded) | — |
--ui-otp-cell-w | var(--ui-field-h) | — |
--ui-otp-gap | calc(var(--spacing) * 1.5) | — |
--ui-otp-separator-text | var(--color-muted-foreground) | — |