Skip to content

Repository files navigation

input-otp

One invisible input, any UI you can imagine.

The accessible, unstyled, fully featured one-time-password component for React.

npm downloads bundle size license

Documentation Β· Examples Β· API Β· Edge cases


Diamond Sponsor πŸ’Ž

Clerk

Clerk β€” the easiest way to add authentication to your application

Hero Sponsors

Evomi Rapidproxy

Evomi β€” residential proxies from $0.49 Β· Rapidproxy β€” residential proxies from $0.55

Why

HTML has no one-time-password control. There is no <input type="otp">, so most products build one out of six separate inputs wired together with keydown handlers that shuffle focus between them β€” and quietly lose SMS autofill, screen reader support, partial paste, undo, and half the keyboard along the way.

input-otp renders exactly one real text input, paints it invisible, and hands you the state to draw whatever you want on top. Everything the browser gives a text field keeps working, because there is still a text field.

  • SMS autofill β€” autocomplete="one-time-code" only means something on a single field
  • Screen readers β€” one control, one name, one value, one caret, one tab stop
  • Every keybinding you didn't implement β€” select-all, word-delete, shift-arrow ranges, undo, the iOS long-press menu
  • Real paste β€” including a partial paste into the middle of a half-filled code
  • Form semantics β€” one name, one entry in FormData, a real <label> that focuses it
  • Unstyled β€” no theme, no class names to override, no CSS to import
  • Small β€” zero dependencies, React 16.8 β†’ 19 (see the size badge above)

Install

npm install input-otp

Usage

maxLength is the number of slots. render receives them and returns your markup β€” that's the whole contract.

'use client'
import { OTPInput } from 'input-otp'

export function VerificationCode() {
  return (
    <OTPInput
      maxLength={6}
      containerClassName="group flex items-center"
      render={({ slots }) => (
        <div className="flex">
          {slots.map((slot, idx) => (
            <Slot key={idx} {...slot} />
          ))}
        </div>
      )}
    />
  )
}

Each slot tells you what to draw:

import type { SlotProps } from 'input-otp'

function Slot({ char, placeholderChar, isActive, hasFakeCaret }: SlotProps) {
  return (
    <div
      className={cn(
        'relative flex h-14 w-12 items-center justify-center',
        'border-y border-r border-border first:rounded-l-md first:border-l last:rounded-r-md',
        'text-[1.375rem] font-medium tabular-nums transition-all duration-200',
        'outline outline-0 outline-foreground/80',
        isActive && 'z-10 outline-2', // this slot is being edited
      )}
    >
      {char ?? placeholderChar}
      {hasFakeCaret && <FakeCaret />} {/* the real caret is transparent */}
    </div>
  )
}

The full, copy-pasteable slot component (with the caret keyframe and the Stripe-style dash) is in Installation.

Using shadcn/ui?

shadcn/ui's input-otp component wraps this library with pre-composed parts. Same engine, <InputOTPSlot index={0} /> instead of a render prop:

npx shadcn@latest add input-otp

What it handles for you

The API is five props. The value is the list of things that go wrong when one invisible input has to behave like six boxes β€” and the fix for each:

A collapsed caret has no slot The selection is rewritten into a one-character range on every selectionchange β€” except at the append position, where a bare caret is meaningful
ArrowLeft appears to skip a slot Direction is inferred from the previous selection, with a guard for leaving insert mode
Deleting doesn't fire selectionchange The event is dispatched by hand when the value shrinks
Password manager badges cover the last slot A badge is detected by known extension markers, then by probing the field's top-right corner; the input widens 40px behind a clip-path β€” no visible layout shift
iOS won't paste into an invisible input The field keeps opacity: 1 and hides itself with transparent colours; paste is handled manually
Autofill paints its own background :autofill is neutralised, and the state is shaken off with a synthetic input event
No JavaScript means no visible field A <noscript> stylesheet turns the input back into a plain visible one

Each of these β€” and a dozen more β€” is written up with the reasoning and the exact code in Edge cases.

Documentation

Introduction Why one input, and what you write
Installation Install, first render, a slot component to copy
Anatomy X-ray the field and watch the selection algorithm run live
Styling Slots, carets, placeholders, groups, data attributes
Validation pattern, pasteTransformer, inputMode
Forms Controlled values, auto-submit, react-hook-form, server actions
Accessibility Labelling, keyboard, what a screen reader hears
Password managers How badge detection works β€” with a live simulator
Mobile & platforms SMS autofill, iOS quirks, autofill styling, no-JS
API reference Every prop, render prop, data attribute and export
Examples A gallery of finished fields to copy
Troubleshooting The questions that come up most

API at a glance

type OTPInputProps = {
  maxLength: number                       // number of slots β€” required

  render?: (props: RenderProps) => React.ReactNode
  children?: React.ReactNode              // …or compose and read OTPInputContext

  value?: string
  onChange?: (newValue: string) => unknown   // a string, not an event
  onComplete?: (value: string) => unknown    // fires once, on the transition to full

  pattern?: string | RegExp               // gates every change; no default
  placeholder?: string                    // per-slot placeholder characters
  pasteTransformer?: (pasted: string) => string

  containerClassName?: string             // the visible wrapper
  // className goes to the invisible input

  textAlign?: 'left' | 'center' | 'right'          // default 'left'
  inputMode?: 'numeric' | 'text' | ...             // default 'numeric'
  pushPasswordManagerStrategy?: 'increase-width' | 'none'
  noScriptCSSFallback?: string | null
  nonce?: string                          // for CSP style-src β€” applied to the injected <style> tag
}

interface SlotProps {
  char: string | null
  placeholderChar: string | null
  isActive: boolean
  hasFakeCaret: boolean
}

Every other <input> attribute is forwarded β€” name, required, disabled, autoFocus, aria-*, data-* β€” and ref points at the real input. spellCheck defaults to false (browsers would underline a full code as a typo); pass spellCheck yourself to override.

Full reference: input-otp.rodz.dev/docs/api.

Contributing

pnpm install
pnpm build:lib          # tsup β†’ packages/input-otp/dist
pnpm dev:playground     # the Playwright target, port 3039
pnpm test               # Playwright, all browsers

Tests live in apps/playground/src/tests. Note that the iOS code path, SMS autofill and password manager badges cannot be covered headlessly β€” see Mobile & platforms.

Sponsors

Diamond Sponsor πŸ’Ž

Clerk

Hero Sponsors

Evomi Rapidproxy
  • Clerk β€” the easiest way to add authentication to your application
  • Evomi β€” residential proxies from $0.49
  • Rapidproxy β€” residential proxies from $0.55

MIT Β© Guilherme Rodz

About

One time passcode Input. Accessible & unstyled.

Topics

Resources

Stars

3.2k stars

Watchers

3 watching

Forks

Used by

Contributors

Languages