Rxova
Skip to content

Usage

Type a code, paste 123-456, or click a middle slot to edit it. It renders and runs right here (OtpInput and useState are already in scope).

Editable — try changing it
function TryIt() {
  const [code, setCode] = useState('')
  const [done, setDone] = useState(null)
  return (
    <div>
      <div style={{ fontSize: '1.25rem' }}>
        <OtpInput
          length={6}
          value={code}
          onChange={setCode}
          onComplete={setDone}
          label="Try @rxova/react-otp-input"
        />
      </div>
      <p style={{ margin: '0.75rem 0 0', color: 'var(--rx-muted)' }}>
        value = <b>{code || 'empty'}</b>
        {done ? ` — completed: ${done}` : ' — type, paste “123-456”, or tap a middle slot'}
      </p>
    </div>
  )
}
Terminal window
pnpm add @rxova/react-otp-input # or: npm i / yarn add / bun add

react (>= 18) is the only peer dependency — nothing else to install, and no stylesheet to import. Then it is one import: import { OtpInput } from '@rxova/react-otp-input'.

import { OtpInput } from '@rxova/react-otp-input'
const [code, setCode] = useState('')
<OtpInput length={6} value={code} onChange={setCode} onComplete={verify} label="One-time code" />
  • One native input. Paste, SMS autofill (autocomplete="one-time-code"), IME, undo, and native <form> submission all come from the platform, not from hand-rolled JavaScript across N inputs.
  • Tap any slot to edit it. The input’s characters sit at their true slot pitch, so a click or tap lands the caret where you touched — which a collapsed single-input field physically can’t do.
  • WebOTP — programmatic SMS retrieval (useWebOTP) that no other OTP library ships.
  • Headless — zero runtime dependencies, no stylesheet, ~3.6 kB brotli, styled entirely with --otp-* tokens and data-* hooks.
  • Correct on the seams — formatted paste, Chrome auto-translate, RTL, alphanumeric, SSR/RSC, and a string onChange that pairs cleanly with every form library.

More on the architecture and its one honest tradeoff in Why this exists.

onChange emits the sanitized string, and the underlying input posts natively under name:

React Hook Form · Formik · React Final Form · TanStack Form · plain <form>

The flagship. Because the underlying input’s characters sit at their true slot pitch, a click or tap lands the caret on the slot under your finger — so you can edit any slot, not just the last one.

Tapping a middle slot to edit it in place

Try it — type a code, then click the third box and retype it:

Editable — try changing it
function Spatial() {
  const [code, setCode] = useState('482913')
  return <OtpInput length={6} value={code} onChange={setCode} label="Editable code" />
}

This is what a collapsed single-input field can’t do: crushing the input to a ~1 px column means a click has no character to land on, so the caret only moves by arrow key.

ValueBehaviour
"spatial" (default)Tap any slot to edit it. Auto-degrades to "crush" on iOS.
"crush"The collapsed-input technique everywhere. Tap-to-edit falls back to keyboard + caret.
<OtpInput length={6} value={code} onChange={setCode} label="Code" /> // spatial (default)
<OtpInput length={6} value={code} onChange={setCode} label="Code" slotInteraction="crush" />

iOS Safari cannot fully style ::selection to transparent, so a selection spanning slots can show a faint native highlight. The default therefore feature-detects iOS and silently falls back to "crush" there — tap-to-edit stays keyboard-plus-caret on iOS, full spatial everywhere else.

  • Keep "spatial" explicitly to force spatial on iOS and accept the highlight.
  • Use "crush" to force the collapsed-input behaviour on every platform.

The escape hatch is one prop, not a fork. It’s the same kind of documented, chosen default as any platform limitation — surfaced here rather than hidden.

The input overlays the whole row with transparent text and a transparent caret. A layout effect measures the rendered slot width and gap, then sets the input’s letter-spacing and text-indent so each glyph centres in its slot. Everything visible — the characters and the blinking caret — is painted per slot from the input’s live selection state.

Receiving the code is where OTP fields quietly break. Here every path is treated as the spec.

Paste a formatted code and the separators are stripped, the digits distributed, overflow truncated, and any selection replaced:

Typing a six-digit code
Editable — try changing it
function Paste() {
  const [code, setCode] = useState('')
  return (
    <div>
      <OtpInput length={6} value={code} onChange={setCode} label="Paste 123-456" />
      <p style={{ fontSize: '0.9rem' }}>
        value = <b>{code || 'empty'}</b>
      </p>
    </div>
  )
}

By default - . _ and whitespace are stripped. Override pasteTransform for anything else:

<OtpInput
length={6}
value={code}
onChange={setCode}
pasteTransform={(text) => text.replace(/[^0-9]/g, '')} // keep digits only
label="Code"
/>

The underlying input carries autocomplete="one-time-code", so iOS and Android surface the code from an incoming SMS above the keyboard — and because there is a single field, the whole code fills at once (N-input libraries famously autofill only the first box). Override the attribute if you need to:

<OtpInput autoComplete="one-time-code" length={6} /* … */ />

Opt into programmatic SMS retrieval — the primitive no other OTP library ships. It’s layered on top of autocomplete, never instead of it, and no-ops everywhere the API is absent:

<OtpInput length={6} value={code} onChange={setCode} webOTP label="Code" />

Full details, including the standalone useWebOTP hook and its AbortController cleanup, in the WebOTP recipe.

Render a mask instead of the value for sensitive codes:

Editable — try changing it
function Masked() {
  const [code, setCode] = useState('1234')
  return <OtpInput length={4} value={code} onChange={setCode} mask label="PIN" />
}

mask accepts true (renders ) or a custom character (mask="*").

A controlled value of undefined, a number, an over-length string, disallowed characters, or SMS junk are all sanitized to the allowed set and clamped to length. A code is data; receiving it must never throw.