Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Otp

One-time passcode entry over a single native input.

Installation

pnpm dlx zazz-ui add otp
npx zazz-ui add otp
yarn dlx zazz-ui add otp
bunx zazz-ui add otp

Also adds fields, input, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.

Examples

Enter the 6-digit code we sent you. Enter all 6 digits.
<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 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.

Attributes

otp attributes
AttributeKindValuesNotes
data-ui="otp"Identityfree valueIdentity token in data-ui.
<ui-otp>Tag formfree valueTag form; styled and, when scripted, registered under this name.
data-otp-slotSlotcell input rail separatorSlot: a part inside the primitive.
data-otp-stateStateactiveState the kit's script writes; read it in selectors, never author it.
data-otp-auto-submitConfigfree valueConfiguration the kit's script reads.
data-otp-completeConfigfree valueConfiguration the kit's script reads.
data-otp-groupsConfigfree valueConfiguration the kit's script reads.
data-otp-indexConfigfree valueConfiguration the kit's script reads.
data-otp-maskConfigfree valueConfiguration the kit's script reads.
data-otp-readyConfigfree valueConfiguration the kit's script reads.
data-otp-typeConfigfree valueConfiguration the kit's script reads.

Requires: fields, input.

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.

otp hooks
HookDefaultState
--ui-otp-caret-colorvar(--ui-field-border-color--focus-within)—
--ui-otp-cell-bgvar(--ui-field-bg)—
--ui-otp-cell-border-colorvar(--ui-field-border-color)—
--ui-otp-cell-border-stylevar(--ui-field-border-style)—
--ui-otp-cell-border-widthvar(--ui-field-border-width)—
--ui-otp-cell-font-sizevar(--font-size-md)—
--ui-otp-cell-hvar(--ui-field-h)—
--ui-otp-cell-roundedvar(--ui-field-rounded)—
--ui-otp-cell-wvar(--ui-field-h)—
--ui-otp-gapcalc(var(--spacing) * 1.5)—
--ui-otp-separator-textvar(--color-muted-foreground)—