Skip to content

Components / UI

OTP Input

A one-time code in cells, from one native input. Paste, SMS autofill, screen readers and Backspace only work in the single-input pattern, so the cells are drawn under the field and a monospace font with a computed letter-spacing puts each glyph in the middle of its cell. Full and valid, the check draws itself; :user-invalid tints the cells only after the reader has been there. Zero JavaScript.

Live demo

one input · paste and autofill work

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 7.3 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 119 · Firefox 121 · Safari 16.5; needs has, user-invalid. Elsewhere: without :has() the cells keep their resting border and the check never shows; the input itself is unchanged.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import OtpInput from '@moonarc/core/OtpInput';
---
<form method="post" action="/verify">
  <OtpInput length={6} name="code" label="Enter the code we sent" required />
  <button type="submit">Verify</button>
</form>

<!-- letters and digits, four cells -->
<OtpInput length={4} charset="alphanumeric" name="invite" aria-label="Invite code" />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/otp-input.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

OtpInput.astro
---
/**
 * OtpInput — a one-time code in cells, zero JS. **One** native input, not
 * one per digit: paste, SMS autofill (`autocomplete="one-time-code"`),
 * screen readers and Backspace all work only in the single-input pattern.
 * The cells are server-rendered boxes under the input; a monospace font with
 * tabular figures, a letter-spacing of one cell minus one character and a
 * matching start padding put every glyph in the middle of its cell, and the
 * caret lands at the front of the next one. The wrapper clips the trailing
 * spacing so no seventh cell appears; the focus ring is drawn on the wrapper
 * through :focus-within so it is never clipped. Full and valid → the check
 * draws itself; `:user-invalid` tints the cells only after the reader has
 * been there. Every attribute you pass goes to the input. The field is
 * left to right on any page, since the glyphs have to meet their cells. A
 * code pasted with spaces or dashes ("123 456") is cut at `length`
 * characters: stripping them needs a script, and this component has none.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends Omit<HTMLAttributes<'input'>, 'type' | 'size' | 'maxlength' | 'pattern' | 'inputmode'> {
  /** Number of characters. */
  length?: number;
  /** Digits only (numeric keyboard, \d pattern) or letters and digits. */
  charset?: 'numeric' | 'alphanumeric';
  name?: string;
  /** Visible label, rendered as <label for>. Without it, pass aria-label. */
  label?: string;
  /** Placeholder glyph per cell; the attribute is what :placeholder-shown reads, so it is always set. */
  placeholder?: string;
  /** Cell width, any CSS length; the height and the font follow. */
  cell?: string;
  /** Gap between cells. */
  gap?: string;
  /** The input's id and the label's for. Unset, unique per render. */
  id?: string;
}

const { length = 6, charset = 'numeric', name = 'code', label, placeholder, cell = '2.5rem', gap = '0.5rem', id, class: className, style, ...rest } = Astro.props;
const n = Math.max(1, Math.min(12, Math.round(length)));
// unique per render, not per name: a sign-in form and a dialog can both carry a field named "code" (the VanishInput rule)
const inputId = id ?? `ma-otp-${name}-${Math.random().toString(36).slice(2, 7)}`;
const pattern = charset === 'numeric' ? `\\d{${n}}` : `[A-Za-z0-9]{${n}}`;
const vars = [`--ma-otp-n:${n}`, `--ma-otp-cell:${cell}`, `--ma-otp-gap:${gap}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
const ph = placeholder ?? '•'.repeat(n);
---

{label && <label class="ma-otp__label" for={inputId}>{label}</label>}
<span class:list={['ma-otp', className]} style={vars}>
  <span class="ma-otp__field">
  <span class="ma-otp__cells" aria-hidden="true">{Array.from({ length: n }).map(() => <i></i>)}</span>
  <input
    id={inputId}
    class="ma-otp__input"
    type="text"
    name={name}
    inputmode={charset === 'numeric' ? 'numeric' : 'text'}
    autocomplete="one-time-code"
    autocapitalize="off"
    autocorrect="off"
    spellcheck="false"
    maxlength={n}
    pattern={pattern}
    placeholder={ph}
    {...rest}
  />
  </span>
  <svg class="ma-otp__check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7" pathLength="1" /></svg>
</span>

<style is:global>
  @layer components {
    :where(.ma-otp) {
      --ma-otp-font: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
      --ma-otp-h: calc(var(--ma-otp-cell) * 1.2);
      position: relative;
      display: inline-block;
      vertical-align: middle;
    }
    :where(.ma-otp__field) {
      /* the cells and the glyphs meet only left to right: on a right-to-left page the text would start at the far end,
         one letter-spacing off every cell. A code is read in Latin order there too */
      direction: ltr;
      display: inline-grid;
      /* the input's trailing letter-spacing would add a phantom cell: the box is exactly n cells and n − 1 gaps */
      inline-size: calc(var(--ma-otp-n) * var(--ma-otp-cell) + (var(--ma-otp-n) - 1) * var(--ma-otp-gap));
      block-size: var(--ma-otp-h);
      border-radius: 0.5em;
      overflow: clip;
      vertical-align: middle;
    }
    :where(.ma-otp__label) {
      display: block;
      margin-block-end: 0.5em;
      font-size: 0.875em;
    }
    :where(.ma-otp__cells) {
      grid-area: 1 / 1;
      display: flex;
      gap: var(--ma-otp-gap);
      pointer-events: none;
    }
    :where(.ma-otp__cells > i) {
      flex: none;
      inline-size: var(--ma-otp-cell);
      block-size: 100%;
      border: 1px solid var(--ma-edge);
      border-radius: 0.45em;
      background: var(--ma-panel);
      transition:
        border-color var(--ma-duration-fast) var(--ma-ease-out),
        background-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-otp__input) {
      /* the field: transparent over the cells; every glyph sits in the middle of its cell through spacing alone. One cell
         wider than the box, which clips it: with the last character typed, the caret sits past the box's end, and an input
         exactly as wide scrolled to show it, which moved every digit off its cell and the first out of sight */
      grid-area: 1 / 1;
      inline-size: calc(100% + var(--ma-otp-cell));
      block-size: 100%;
      margin: 0;
      padding: 0 0 0 calc((var(--ma-otp-cell) - 1ch) / 2);
      border: 0;
      background: transparent;
      color: inherit;
      font: 500 calc(var(--ma-otp-cell) * 0.5) / 1 var(--ma-otp-font);
      font-variant-numeric: tabular-nums;
      letter-spacing: calc(var(--ma-otp-cell) + var(--ma-otp-gap) - 1ch);
      text-transform: uppercase;
      caret-color: var(--ma-ink);
      outline: none;
      appearance: none;
      -webkit-appearance: none;
    }
    :where(.ma-otp__input)::placeholder {
      color: color-mix(in srgb, var(--ma-ink) 28%, transparent);
      opacity: 1;
    }
    :where(.ma-otp__input:disabled) {
      cursor: not-allowed;
      opacity: 0.6;
    }
    :where(.ma-otp__input:read-only) {
      caret-color: transparent;
    }
    /* focus: on the field's box, outside the clip; one cell at a time is not known without a script */
    :where(.ma-otp__field:focus-within) {
      outline: 2px solid var(--ma-ink);
      outline-offset: 3px;
    }
    :where(.ma-otp:has(.ma-otp__input:focus) .ma-otp__cells > i) {
      border-color: color-mix(in srgb, var(--ma-ink) 45%, transparent);
    }
    /* full and valid: the cells go ink-edged and the check draws */
    :where(.ma-otp:has(.ma-otp__input:valid:not(:placeholder-shown)) .ma-otp__cells > i) {
      border-color: var(--ma-ink);
    }
    /* the red is a hook: on a coloured ground a caller points --ma-otp-invalid at a red that keeps 3 : 1 there */
    :where(.ma-otp:has(.ma-otp__input:user-invalid) .ma-otp__cells > i) {
      border-color: var(--ma-otp-invalid, #e0563f);
      background: color-mix(in srgb, var(--ma-otp-invalid, #e0563f) 8%, var(--ma-panel));
    }
    :where(.ma-otp:has(.ma-otp__input:disabled) .ma-otp__cells > i),
    :where(.ma-otp:has(.ma-otp__input:disabled) .ma-otp__cells > i) {
      transition: none;
      background: color-mix(in srgb, var(--ma-ink) 6%, var(--ma-panel));
    }
    /* the check: beside the field, never over the last digit */
    :where(.ma-otp__check) {
      position: absolute;
      inset-block-start: 50%;
      inset-inline-start: 100%;
      inline-size: calc(var(--ma-otp-cell) * 0.55);
      block-size: calc(var(--ma-otp-cell) * 0.55);
      margin-inline-start: calc(var(--ma-otp-gap) * 1.2);
      translate: 0 -50%;
      fill: none;
      stroke: var(--ma-ink);
      stroke-width: 2.5;
      stroke-linecap: round;
      stroke-linejoin: round;
      pointer-events: none;
      opacity: 0;
    }
    :where(.ma-otp__check path) {
      stroke-dasharray: 1;
      stroke-dashoffset: 1;
      transition: stroke-dashoffset var(--ma-duration-fast) var(--ma-ease-in);
    }
    :where(.ma-otp:has(.ma-otp__input:valid:not(:placeholder-shown)) .ma-otp__check) {
      opacity: 1;
    }
    :where(.ma-otp:has(.ma-otp__input:valid:not(:placeholder-shown)) .ma-otp__check path) {
      stroke-dashoffset: 0;
      transition: stroke-dashoffset var(--ma-duration) var(--ma-ease);
    }
    @media (forced-colors: active) {
      :where(.ma-otp__cells > i) {
        border-color: ButtonText;
        background: Canvas;
      }
      :where(.ma-otp:has(.ma-otp__input:valid:not(:placeholder-shown)) .ma-otp__cells > i) {
        border-color: Highlight;
      }
      :where(.ma-otp__check) {
        stroke: Highlight;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-otp__cells > i),
      :where(.ma-otp__check path) {
        transition: none;
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
lengthnumber6Number of cells (1–12); maxlength and the pattern follow.
charset'numeric' | 'alphanumeric''numeric'Digits only (numeric keyboard on phones, \d pattern), or letters and digits.
namestring'code'Form field name; the whole code is one value.
labelstringnoneVisible label as <label for>. Without it, pass aria-label.
placeholderstring'•' × lengthOne glyph per cell. Always set: :placeholder-shown is what tells an empty field from a full one.
cellstring'2.5rem'Cell width; the height (× 1.2) and the font size (× 0.5) follow.
gapstring'0.5rem'Space between cells.
idstringunique per renderThe input's id, and the label's for. Unset, ma-otp- with the name and a random suffix, so two fields of the same name on one page never share an id. Yours wins.
--ma-otp-invalidCSS colour (custom property)#e0563fThe border and tint of the cells once the reader has left the field invalid (:user-invalid). Set it in a rule or style on the component or any ancestor that knows its ground: a red that keeps 3 : 1 against it (WCAG 1.4.11).

Reduced motion

The cells change border at once and the check appears without drawing.

With ClientRouter

A native input; nothing to bind. The browser keeps a typed value through a navigation only where it keeps any form value.

Why it is built this way

Replaces: InputOTP (shadcn) · OTPInput (Motion Primitives) · react-otp-input. See the migration table.

a tick that draws · one indeterminate

A checkbox whose tick is drawn.

every browserclick

1,284

A number whose digits roll to the new value when it changes.

newly · Chrome 125 · Firefox 118 · Safari 15.4click

idle → pending → done · one attribute

One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label.

every browserclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown