input-otp
input-otp is a React component for one-time-code fields, the six boxes you type an SMS or authenticator code into. HTML has no control for this, so the usual build is six separate inputs wired together with keydown handlers that move focus around, and that approach quietly breaks SMS autofill, screen readers, partial paste, undo, and half the keyboard shortcuts. input-otp renders one real text input, makes it visually transparent, and gives you the per-slot state so you can draw whatever boxes you want on top. Because a single text field is still there, autocomplete="one-time-code", form submission, labels, and the platform keyboard behaviour all keep working. You give it maxLength for the number of slots and a render function that receives the slots; it gives back char, placeholderChar, isActive, and hasFakeCaret for each one. It ships no styles and has zero dependencies.
The best-argued OTP input for React: one real field, so autofill, paste, and screen readers work, at under 4 KB with no dependencies. Accept that you are writing the visuals yourself and that the stable release trails the documentation.
Use it if
- You want iOS and Android SMS autofill to work: the one-time-code autocomplete hint only fires on a single field, so any six-input implementation loses it
- Accessibility is a requirement. One input means one tab stop, one accessible name, one value, and one caret, instead of six controls a screen reader reads as six unrelated text boxes
- You want the field to look exactly like your design: separators, group gaps, animated carets, per-slot states are all yours to draw, and the package imposes no CSS at all
- You need real paste behaviour, including pasting a code into a half-filled field, plus undo, select-all, and shift-arrow selection working the way users expect
- You already use shadcn/ui, whose input-otp component is a thin wrapper over this library, so you are running it either way
- You are not on React with a DOM. react and react-dom are both peer dependencies, so React Native and non-React frameworks are out; look for a platform-specific component instead
- You do not want to write the field yourself. There are no default styles and the caret is a fake element you have to animate; the copy-pasteable slot component lives on the docs site, not in the package, so budget an hour of CSS
- You think installing this makes OTP login secure. It is only the input: code generation, delivery, expiry, attempt limits, and replay protection are all server-side work this package does not touch
- You need what the README and docs describe today. The latest stable release is 1.4.2 from January 2025 while the documentation tracks the 1.5 line, currently published only as 1.5.0-beta.1, so props described there such as nonce for a strict style-src policy are not in the stable build
- Your app runs a strict Content Security Policy. Version 1.4.2 renders a noscript style block with no nonce support, so you either allow it or pass noScriptCSSFallback={null} and give up the no-JavaScript fallback
Setup reality
npm install input-otp, add 'use client' if you are in the Next.js App Router, and render an OTPInput with maxLength and a render prop. There are no dependencies and no CSS import. The real work starts after that: you write the slot component, the border and rounding rules for first and last slot, the active-slot ring, and the keyframes for the fake caret, because the transparent input has no visible caret of its own. Two smaller traps. The published TypeScript types inherit pattern from the standard input attributes and declare it as a string, even though the runtime happily accepts a RegExp, so pass one of the exported REGEXP_ONLY_DIGITS style constants and avoid the argument. And password manager badge handling defaults to widening the input by 40 pixels behind a clip-path, which is usually invisible but can be surprising if you measure the container yourself.
Patterns
A six-slot code fieldbasic-otp-field
'use client';
import { OTPInput } from 'input-otp';
export function CodeField() {
return (
<OTPInput
maxLength={6}
containerClassName="flex items-center gap-2"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, i) => <Slot key={i} {...slot} />)}
</div>
)}
/>
);
}maxLength is the slot count. containerClassName styles the visible wrapper; a plain className goes to the invisible input, which is almost never what you want.
Draw one slot, including the fake caretslot-component
import type { SlotProps } from 'input-otp';
function Slot({ char, placeholderChar, isActive, hasFakeCaret }: SlotProps) {
return (
<div
className={[
'relative flex h-14 w-12 items-center justify-center border text-xl',
isActive ? 'ring-2 ring-offset-0' : '',
].join(' ')}
>
{char ?? placeholderChar}
{hasFakeCaret && <span className="animate-caret-blink h-8 w-px bg-current" />}
</div>
);
}The real caret is transparent, so hasFakeCaret is the only signal that this slot is where typing lands. Without an animation on that element the field looks frozen while focused.
Control the value from statecontrolled-value
const [code, setCode] = useState('');
<OTPInput
maxLength={6}
value={code}
onChange={setCode}
render={({ slots }) => <Slots slots={slots} />}
/>onChange receives the string itself, not a change event, so setCode passes straight in. Feeding back a value longer than maxLength is ignored rather than truncated silently at the source.
Submit as soon as the code is fullauto-submit-on-complete
const [pending, setPending] = useState(false);
<OTPInput
maxLength={6}
disabled={pending}
onComplete={async (value) => {
setPending(true);
try {
await verifyCode(value);
} finally {
setPending(false);
}
}}
render={({ slots }) => <Slots slots={slots} />}
/>onComplete fires once on the transition to a full value, not on every keystroke afterwards. Disable the field while the request is in flight or a fast second paste fires a duplicate verification.
Restrict input to digitsdigits-only
import { OTPInput, REGEXP_ONLY_DIGITS } from 'input-otp';
<OTPInput
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
inputMode="numeric"
render={({ slots }) => <Slots slots={slots} />}
/>pattern gates every change, including paste, and there is no default, so a field with no pattern accepts any character. REGEXP_ONLY_CHARS and REGEXP_ONLY_DIGITS_AND_CHARS are exported too. inputMode only picks the mobile keyboard; it enforces nothing.
Accept codes pasted with spaces or dashesclean-pasted-codes
<OTPInput
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
pasteTransformer={(pasted) => pasted.replace(/[\s-]/g, '')}
render={({ slots }) => <Slots slots={slots} />}
/>Email clients and SMS apps hand over codes like "123 456". Without the transformer the pattern rejects the paste outright and the user sees nothing happen at all.
Use it in a plain form or a server actionform-integration
<form action={verifyAction}>
<label htmlFor="otp">Verification code</label>
<OTPInput
id="otp"
name="otp"
required
maxLength={6}
render={({ slots }) => <Slots slots={slots} />}
/>
<button type="submit">Verify</button>
</form>Because there is one real input, name produces one FormData entry and a real label focuses it. Every unrecognised prop is forwarded to the input, so required, autoFocus, and aria attributes work as normal.
Wire it into react-hook-formreact-hook-form
import { Controller, useForm } from 'react-hook-form';
const { control, handleSubmit } = useForm({ defaultValues: { otp: '' } });
<Controller
control={control}
name="otp"
rules={{ minLength: 6, required: true }}
render={({ field }) => (
<OTPInput
maxLength={6}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
render={({ slots }) => <Slots slots={slots} />}
/>
)}
/>Controller is needed because onChange hands over a string rather than an event. The ref points at the real input, so react-hook-form can focus the field when validation fails.
Compose slots instead of using a render propcomposition-with-context
import { OTPInput, OTPInputContext } from 'input-otp';
import { useContext } from 'react';
function Slot({ index }: { index: number }) {
const { slots } = useContext(OTPInputContext);
const slot = slots[index];
return <div data-active={slot.isActive}>{slot.char}</div>;
}
<OTPInput maxLength={6}>
<Slot index={0} />
<Slot index={1} />
<span>-</span>
<Slot index={2} />
</OTPInput>Pass children instead of render and read OTPInputContext inside them. This is how shadcn/ui builds its InputOTPSlot and InputOTPSeparator parts; render and children are mutually exclusive.
Show placeholder characters in empty slotsplaceholder-slots
<OTPInput
maxLength={6}
placeholder="000000"
textAlign="center"
render={({ slots }) => (
<div className="flex">
{slots.map((slot, i) => (
<Slot key={i} {...slot} />
))}
</div>
)}
/>Each slot receives its own placeholderChar, so render char ?? placeholderChar and style the placeholder state with a muted colour. textAlign changes where the caret sits when the field is empty.
Clear the field and refocus after a wrong codefocus-and-reset
const inputRef = useRef<HTMLInputElement>(null);
const [code, setCode] = useState('');
async function onComplete(value: string) {
const ok = await verifyCode(value);
if (!ok) {
setCode('');
inputRef.current?.focus();
}
}
<OTPInput ref={inputRef} value={code} onChange={setCode} maxLength={6}
onComplete={onComplete} render={({ slots }) => <Slots slots={slots} />} />ref is the actual input element, so the normal DOM methods apply. Clearing state alone leaves focus where it was, which on mobile can dismiss the keyboard and strand the user.
Strict CSP and password manager badgescsp-and-password-managers
<OTPInput
maxLength={6}
// no nonce prop in 1.4.2: drop the injected <noscript> styles instead
noScriptCSSFallback={null}
// stop the field widening to dodge 1Password and LastPass badges
pushPasswordManagerStrategy="none"
render={({ slots }) => <Slots slots={slots} />}
/>The default noscript style block makes the input visible when JavaScript is off, but it is inline CSS that a strict style-src rejects. Setting the strategy to none keeps your layout measurements exact at the cost of a password manager badge possibly covering the last slot.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react-otp-input | npm | You want the traditional multiple-input component with styling props and no render-prop work. |
| @radix-ui/react-one-time-password-field | npm | You already build on Radix primitives and want their composition and accessibility conventions. |
| react-hook-form | npm | The OTP field is one part of a larger form and validation and submission wiring is the actual problem. |