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/otpRequires 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: -
<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: -
<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-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); }
}<Input placeholder=" " className="otp-pop" />Underline that fills on focus
.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-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); }
}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) => voidCalled 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
booleanMasks every input.
- joinerdefault ''
stringPlaced between input values in onChange, e.g. "-".
- pattern
stringHTML pattern applied to every input. Best with type="any".
- children
ReactNodeInputs, label and any other elements. Root renders no element of its own.
| Prop / Type | Default | Description |
|---|---|---|
onChange (value: string) => void | — | Called with the full code, in input order, whenever it changes. Not called on first render. |
type 'any' | 'number' | 'text' | 'any' | number: digits only. text: letters and digits. any: no restriction. |
password boolean | false | Masks every input. |
joiner 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:
<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
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
<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>