OTP — <mv-otp>

One-time code input: a real hidden field handles typing, pasting and SMS autofill, while animated slots display the code with a blinking caret, a separator and an error shake.

CategoryForms
TypeWeb Component (<mv-otp>)
Statusstable
Keywordsotp, code, pin, 2fa, verification, one-time-code, form-associated

When to use

Avoid when

Install

node scripts/add.mjs otp --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["otp"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/otp/otp.js, components/otp/otp.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div style="display:grid;gap:1.75rem;justify-items:center;text-align:center">
  <form id="otp-demo-verify" style="display:grid;gap:.6rem;justify-items:center">
    <label for="otp-demo-code" style="font-size:.875rem;font-weight:600">Verification code</label>
    <mv-otp id="otp-demo-code" name="code" length="6" separator required aria-describedby="otp-demo-hint"></mv-otp>
    <p id="otp-demo-hint" style="margin:0;font-size:.8125rem;color:var(--mv-fg-muted)" aria-live="polite">Sent by text to (•••) •••-••42 — try <strong>424242</strong>.</p>
  </form>

  <div style="display:flex;gap:2.5rem;flex-wrap:wrap;justify-content:center">
    <div style="display:grid;gap:.5rem;justify-items:center">
      <label for="otp-demo-ref" style="font-size:.8125rem;font-weight:500;color:var(--mv-fg-muted)">Referral code</label>
      <mv-otp id="otp-demo-ref" length="6" pattern="alphanumeric" case="upper" separator="3" placeholder="–" data-size="sm"></mv-otp>
    </div>
    <div style="display:grid;gap:.5rem;justify-items:center">
      <label for="otp-demo-pin" style="font-size:.8125rem;font-weight:500;color:var(--mv-fg-muted)">PIN</label>
      <mv-otp id="otp-demo-pin" length="4" mask value="2718" data-size="sm"></mv-otp>
    </div>
  </div>
</div>
<script type="module">
  const otp = document.getElementById("otp-demo-code");
  const hint = document.getElementById("otp-demo-hint");
  const initial = hint.innerHTML;
  otp?.addEventListener("mv-complete", (e) => {
    if (e.detail.value === "424242") {
      otp.valid = true;
      hint.textContent = "Code confirmed, redirecting…";
      hint.style.color = "var(--mv-success)";
    } else {
      otp.invalid = true;
      hint.textContent = "Incorrect code, try again.";
      hint.style.color = "var(--mv-danger)";
    }
  });
  otp?.addEventListener("mv-change", (e) => {
    if (!e.detail.complete && hint.style.color) { hint.innerHTML = initial; hint.style.color = ""; }
  });
</script>

API

Attributes

NameTypeDefaultDescription
lengthnumber6Number of slots (1–12).
patternnumeric | alphanumeric | alpha | <single-character regex>numericAccepted characters; “numeric” opens the number pad on mobile.
caseupper | lowerForces the case of typed characters.
separatorboolean | "3" | "2,4"Visual separator: in the middle if empty, otherwise after the given positions.
maskbooleanMasks the characters (•), for a PIN.
placeholderstringCharacter(s) shown in empty slots (e.g. “–” or “000000”).
valuestringInitial value (and form reset value).
namestringField name in the form.
requiredbooleanInvalid until every slot is filled.
invalidbooleanError state (red border + shake when set); cleared on the next input.
validbooleanSuccess state (green border and tint); cleared on the next input.
disabledbooleanDisables input.
labelstringAccessible name when no <label for> is associated.
data-sizesm | lgSlot size.

Properties

NameTypeDescription
valuestringCurrent code (filtered and truncated).
completebooleanRead-only: every slot is filled.
form / validity / validationMessageNative form API (ElementInternals).

Methods

NameDescription
focus()Focuses the real input.
clear()Clears the code and the valid/invalid states.
shake()Plays the shake animation.
checkValidity() / reportValidity()Native validation.

Events

NameDescription
mv-changedetail: { value, complete } on every change.
mv-completedetail: { value } when the last slot is filled.

CSS classes

NameDescription
mv-otp-slot / -char / -caret / -separatorGenerated parts (data-active, data-filled).

CSS variables

NameDefaultDescription
--mv-otp-slot-width2.75remSlot width.
--mv-otp-slot-height3remSlot height.
--mv-otp-gapvar(--mv-space-2)Space between slots.
--mv-otp-radiusvar(--mv-radius-md)Slot radius.

Accessibility

A single real <input> (autocomplete="one-time-code", numeric inputmode) holds focus and the accessible name (from <label for>, label or aria-label); the visual slots are aria-hidden. Arrow keys, Home/End, Backspace and Delete move between slots; pasting spreads the code. aria-invalid reflects the error state; form-associated element with native validation.