# OTP Input (Moonarc)

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.

- Import: `import OtpInput from '@moonarc/core/OtpInput'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/otp-input.json`
- Tier A · category ui · trigger click
- Readout: `<OtpInput length={6} name="code" required>`
- Browser support: widely (Chrome 119 · Firefox 121 · Safari 16.5); elsewhere: without :has() the cells keep their resting border and the check never shows; the input itself is unchanged
- Measured cost: 0 B JS (CSS 7.3 kB raw)
- Page: https://moonarc.dev/components/otp-input/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `length` | `number` | `6` | Number 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. |
| `name` | `string` | `'code'` | Form field name; the whole code is one value. |
| `label` | `string` | none | Visible label as <label for>. Without it, pass aria-label. |
| `placeholder` | `string` | `'•' × length` | One glyph per cell. Always set: :placeholder-shown is what tells an empty field from a full one. |
| `cell` | `string` | `'2.5rem'` | Cell width; the height (× 1.2) and the font size (× 0.5) follow. |
| `gap` | `string` | `'0.5rem'` | Space between cells. |
| `id` | `string` | `unique per render` | The 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-invalid` | `CSS colour (custom property)` | `#e0563f` | The 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). |

## Usage

```astro
<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" />
```

## 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.

## Craft

- One input, not six. Six inputs need a script to move focus, split a paste, accept an SMS autofill and make Backspace step back, and they still confuse a screen reader. One field with autocomplete="one-time-code" gets all of that from the browser.
- The cells are rendered boxes under a transparent field. In a monospace font every glyph advances 1ch, so a letter-spacing of cell + gap − 1ch and a start padding of (cell − 1ch) / 2 centre each character in its cell, and the caret sits at the front of the next one. Nothing is measured.
- The spacing after the last glyph would draw a seventh cell: the field is exactly n cells wide and clips it, the focus ring is drawn on the field's box through :focus-within so the clip never cuts it, and the check sits beside the field, never over the last digit.
- Full and valid is :valid:not(:placeholder-shown). That is why the placeholder attribute is always present: without it an empty, non-required field is :valid and the check would be lit from the start.
- The error tint uses :user-invalid, so the cells are never red before the reader has typed or submitted. required and pattern do the validating; the browser shows the message.
- The active-cell highlight is not built: it needs the value length, which is a script. Listed as a proposal.
- A code pasted with spaces or dashes ("123 456", "123-456") is cut at length characters by maxlength, and the pattern then refuses it: removing separators needs a script. SMS autofill and a copied code without separators fill the field whole.
- The field is direction: ltr on every page, so on a right-to-left page the glyphs still start in the first cell; the label and the check follow the page's direction.

## Replaces

- InputOTP (shadcn)
- OTPInput (Motion Primitives)
- react-otp-input

## Source

```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>

```
