OTP Input

Headless, customizable OTP input for React with SMS autofill, paste handling, keyboard navigation, password masking and right-to-left support.

Features

  • Headless: no styles or wrapper element, style it however you like
  • SMS autofill: iOS and Android can offer the code from a text message
  • Paste anywhere: pasted codes spread across inputs, spaces are ignored
  • Keyboard friendly: auto-advance, Backspace steps back, arrow keys move between inputs
  • Fills in order: clicking an empty input jumps to the first unfilled one
  • Mobile keyboards: type="number" opens the numeric keypad
  • International: Arabic-Indic and Persian digits become 0-9, and it works on right-to-left pages (Arabic, Hebrew) because the code is still entered left to right
  • Multi-character inputs, validation and password masking

Usage

npm install @frjoy/otp

Requires React 18 or newer.

Try it in the Storybook playgroundLive controls for every prop, ready-made examples (SMS code, PIN, license key, Arabic) and interaction tests.

Live Demo 1: Basic OTP

Entered OTP: -

Example.tsx
<Root onChange={setOtp} type="number" password>
  <Label>Enter OTP</Label>
  {[...Array(6)].map((_, i) => (
    <Input key={i} length={1} />
  ))}
</Root>

Live Demo 2: Alphanumeric OTP with Joiner

This example shows a referral-style OTP code joined with -.

Joined Code: -

Example.tsx
<Root onChange={setOtp} type="text" joiner="-">
  <Label>Referral Code</Label>
  {[...Array(4)].map((_, i) => (
    <Input key={i} length={2} />
  ))}
</Root>

Typing Animations

Each Input is a real input that takes your className, so animations are plain CSS. An empty placeholder=" " lets :placeholder-shown tell empty and filled boxes apart.

Pop when a box fills

otp.css
.otp-pop:not(:placeholder-shown) {
  animation: otp-pop 160ms ease-out;
}
@keyframes otp-pop {
  0% { transform: scale(0.8); }
  60% { transform: scale(1.08); }
  100% { transform: scale(1); }
}
Example.tsx
<Input placeholder=" " className="otp-pop" />

Underline that fills on focus

otp.css
.otp-line {
  border: 0;
  outline: none;
  background-image: linear-gradient(#3b82f6, #3b82f6),
    linear-gradient(#d4d4d4, #d4d4d4);
  background-size: 0% 2px, 100% 2px;
  background-position: bottom;
  background-repeat: no-repeat;
  transition: background-size 200ms;
}
.otp-line:focus,
.otp-line:not(:placeholder-shown) {
  background-size: 100% 2px, 100% 2px;
}

Shake on a wrong code

onChange on Root gives you the whole code, so you can set a class when it is wrong. Try any 4 digits; the right code is 1234.

otp.css
.otp-wrong input {
  border-color: #dc2626;
  animation: otp-shake 300ms;
}
.otp-right input { border-color: #16a34a; }
@keyframes otp-shake {
  25% { transform: translateX(-4px); }
  75% { transform: translateX(4px); }
}
Example.tsx
const [status, setStatus] = useState('');
const check = (v) => {
  if (v.length < 4) setStatus('');
  else setStatus(v === '1234' ? 'right' : 'wrong');
};

<div className={status && `otp-${status}`}>
  <Root type="number" onChange={check}>
    {[...Array(4)].map((_, i) => (
      <Input key={i} placeholder=" " className="otp-pop" />
    ))}
  </Root>
</div>

Props Reference

  • onChange
    (value: string) => void

    Called with the full code, in input order, whenever it changes. Not called on first render.

  • typedefault 'any'
    'any' | 'number' | 'text'

    number: digits only. text: letters and digits. any: no restriction.

  • passworddefault false
    boolean

    Masks every input.

  • joinerdefault ''
    string

    Placed between input values in onChange, e.g. "-".

  • pattern
    string

    HTML pattern applied to every input. Best with type="any".

  • children
    ReactNode

    Inputs, label and any other elements. Root renders no element of its own.

Accessibility

The component adds no text of its own, so labels stay in your language. Give each input an aria-label:

Example.tsx
<Input aria-label="Digit 1 of 6" />

Interactive Playground

Experiment with different props and see the changes in real-time. For more examples, see the Storybook playground.

Quick Presets

Configuration

Live Preview

Output:
No input yet

Length: 0 characters

Expected total: 6 characters

Try these features:

  • • Paste complete codes, even with spaces - they'll auto-fill
  • • Use arrow keys to navigate between inputs
  • • Backspace deletes and moves to previous input
  • • Click an empty input - focus jumps to the first unfilled one
  • • Arabic-Indic digits like ١٢٣٤ become 1234

Generated Code

VerifyCode.tsx
<Root 
  onChange={setOtp}
>
  <Label>Enter Code</Label>
  <div className="flex gap-1">
    {[...Array(6)].map((_, i) => (
      <Input
        key={i}
        length={1}
        className="min-w-12 px-3 py-2 border border-neutral-300 dark:border-neutral-700 rounded focus:ring-2 focus:ring-blue-500 dark:bg-neutral-800 dark:text-white"
      />
    ))}
  </div>
</Root>

Share this package

Want to improve this page? Create a Pull Request