guilhermerodz/input-otp

One time passcode Input. Accessible & unstyled.

JavaScript

3,268

402 commits

updated Sep 10, 2026

See the code

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 Sponsor

Evomi

Evomi — residential proxies from $0.49

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 slotThe 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 slotDirection is inferred from the previous selection, with a guard for leaving insert mode
Deleting doesn't fire selectionchangeThe event is dispatched by hand when the value shrinks
Password manager badges cover the last slotA 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 inputThe 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 fieldA <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

IntroductionWhy one input, and what you write
InstallationInstall, first render, a slot component to copy
AnatomyX-ray the field and watch the selection algorithm run live
StylingSlots, carets, placeholders, groups, data attributes
Validationpattern, pasteTransformer, inputMode
FormsControlled values, auto-submit, react-hook-form, server actions
AccessibilityLabelling, keyboard, what a screen reader hears
Password managersHow badge detection works — with a live simulator
Mobile & platformsSMS autofill, iOS quirks, autofill styling, no-JS
API referenceEvery prop, render prop, data attribute and export
ExamplesA gallery of finished fields to copy
TroubleshootingThe 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 Sponsor

Evomi

  • Clerk — the easiest way to add authentication to your application
  • Evomi — residential proxies from $0.49

2fa
input
mfa
otp
otp-verification
react

guilhermerodz/input-otp

One time passcode Input. Accessible & unstyled.

JavaScript

3,268

402 commits

updated Sep 10, 2026

See the code

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 Sponsor

Evomi

Evomi — residential proxies from $0.49

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 slotThe 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 slotDirection is inferred from the previous selection, with a guard for leaving insert mode
Deleting doesn't fire selectionchangeThe event is dispatched by hand when the value shrinks
Password manager badges cover the last slotA 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 inputThe 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 fieldA <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

IntroductionWhy one input, and what you write
InstallationInstall, first render, a slot component to copy
AnatomyX-ray the field and watch the selection algorithm run live
StylingSlots, carets, placeholders, groups, data attributes
Validationpattern, pasteTransformer, inputMode
FormsControlled values, auto-submit, react-hook-form, server actions
AccessibilityLabelling, keyboard, what a screen reader hears
Password managersHow badge detection works — with a live simulator
Mobile & platformsSMS autofill, iOS quirks, autofill styling, no-JS
API referenceEvery prop, render prop, data attribute and export
ExamplesA gallery of finished fields to copy
TroubleshootingThe 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 Sponsor

Evomi

  • Clerk — the easiest way to add authentication to your application
  • Evomi — residential proxies from $0.49

2fa
input
mfa
otp
otp-verification
react

Project health

Latest release

1.5.0 · Aug 18, 2026

Releases this year

1

Significant stargazers

(top 24 of 40)

Christian Oliff

561 followers · starred Feb 2024

Hayden Bleasel

1,552 followers · starred Mar 2024

Amila Welihinda

1,467 followers · starred Feb 2025

Chirag Aggarwal

244 followers · starred Feb 2024