guilhermerodz/input-otp

JavaScriptinput-otp.rodz.dev

One time passcode Input. Accessible & unstyled.

2fainputmfaotpotp-verificationreact
スター成長
スター
3.2k
フォーク
90
週間成長
+38
Issue
3
1.5k2k2.5k3k
2024年2月2024年12月2025年10月2026年9月
成果物npm
README

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 autofillautocomplete="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?: (...args: any[]) => unknown // fires once, on the transition to full;
                                           // receives the value as a string (narrows in 2.0)

  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

関連リポジトリ
tailscale/tailscale

The easiest, most secure way to use WireGuard and 2FA.

GoGo ModulesBSD 3-Clause "New" or "Revised" Licensewireguardoauth
tailscale.com
36.2k3.2k
authelia/authelia

The Single Sign-On Multi-Factor portal for web apps. OpenID Certified™ and Post-Quantum Cryptography Ready.

GoGo ModulesApache License 2.0totpldap
authelia.com
28.8k1.5k
ente/ente

💚 End-to-end encrypted cloud for everything.

DartappGNU Affero General Public License v3.0androidios
ente.com
28.7k1.8k
zitadel/zitadel

ZITADEL - Identity infrastructure, simplified for you.

GoGo ModulesGNU Affero General Public License v3.0samlauthentication
zitadel.com
15k1.3k
beemdevelopment/Aegis

A free, secure and open source app for Android to manage your 2-step verification tokens.

JavaMavenappGNU General Public License v3.0otptotp
getaegis.app
13k588
teamhanko/hanko

Modern authentication, on your terms. Open source alternative to Auth0, Clerk, WorkOS, Stytch.

GoGo ModulesOtherpasskeyswebauthn
hanko.io
9k1k
tinyauthapp/tinyauth

The tiniest OpenID Certified™ authorization and authentication server you have ever seen.

GoGo ModulesGNU Affero General Public License v3.02faauthentication
tinyauth.app
8.2k265
Authenticator-Extension/Authenticator

Authenticator generates 2-Step Verification codes in your browser.

TypeScriptnpmappMIT Licenseauthenticator2fa
authenticator.cc
4.7k1.2k
stratumauth/app

📱 Two-Factor Authentication (2FA) client for Android + Wear OS

C#appGNU General Public License v3.0androidtwo-factor-authentication
stratumauth.com
4.6k278
Bubka/2FAuth

A Web app to manage your Two-Factor Authentication (2FA) accounts and generate their security codes

PHPPackagistGNU Affero General Public License v3.0otp2fa
docs.2fauth.app
4.1k300
yeojz/otplib

One Time Password (OTP) / 2FA for Node.js and Browser - Supports HOTP, TOTP and Google Authenticator

TypeScriptnpmcliMIT Licensehotphmac
otplib.yeojz.dev
2.3k147
mkhorasani/Streamlit-Authenticator

A secure authentication module to manage user access in a Streamlit application.

PythonPyPIMIT Licenseauthenticationpython
2.1k289