React OTP Input: Paste, SMS Autofill and a Resend Timer
A verification code screen looks like an afternoon of work: six boxes, focus jumps forward, done. The bugs show up later, on real phones. Six inputs capped at one character each turn the code the iPhone offers above the keyboard, or a pasted one, into a single digit. Backspace on an empty box does nothing, and the resend link fires as fast as someone can tap it. This guide covers what a React OTP input has to handle, why SMS autofill fails without an error, and a complete verify form with react-hook-form, zod and a resend timer. The examples use the free OTP Input from Wingo UI, the library we build, so we are not neutral; outside facts link to their sources.
What should a React OTP input handle?
It has to accept a code four ways (typing, paste, the keyboard's suggestion from a text message and, on Android, the WebOTP API) and still edit like one field. Our checklist, and a fair test for any library:
- Typing moves to the next slot, arrows move between slots, and a tap past the first empty slot lands on that slot, so typing never skips one.
- Paste fills the slots at once and keeps only the characters they accept, so "123 456" and "Your code is 123456" both fill six digits.
- SMS autofill needs
autocomplete="one-time-code"on the slot that receives the code, and no one-character limit on it. - Backspace clears the slot you are in. The next press steps back and clears the one before.
- The right keyboard:
inputMode="numeric"for digits, a full keyboard with capitals fortype="letters"or"both". - Feedback:
status="error"rings the slots red and shakes the row once,"success"traces a green ring around each slot, and reduced motion turns the motion off. - Names: each slot reads as "Digit 1 of 6" (
slotLabeltranslates it), the row needs a group label, and the error needs words next to the red ring.
Install it and drop it into a client component:
length sets the slot count (4 for a PIN), type the accepted characters, mask hides them like a password field, and size picks 40, 48 or 56px slots. The sm size grows to 44px on touch screens, and in a container narrower than the row the slots shrink evenly and stay square, so eight slots still fit a 360px phone. More on sizing for thumbs in mobile-first React components.
$ npx wingo-ui@latest add otp-inputHow does autocomplete one-time-code autofill work?
The autocomplete="one-time-code" attribute is a hint to the browser. The operating system spots a code in an incoming text message, and when the focused field carries that token, the keyboard offers the code; one tap inserts the whole code into that field at once. MDN defines the token (opens in a new tab) as "a one-time password (OTP) for verifying user identity that is used as an additional factor in a sign-in flow."
Support for autocomplete one-time-code depends on the platform. web.dev's SMS OTP form guide (opens in a new tab) (last updated December 2020, still the current version when we checked in October 2026) says the attribute works in Safari 12 and later on iOS, iPadOS and macOS, while Chrome, Opera and Vivaldi on Android support origin-bound codes through the WebOTP API, "though not through autocomplete="one-time-code"."
Since iOS 14 and macOS Big Sur, Apple also lets you bind a code to your domain. Per Apple's documentation on domain-bound codes (opens in a new tab), the last line of the message carries your domain after an @ and the code after a #, separated by a space, and AutoFill only suggests a domain-bound code when the site open in Safari matches that domain:
Chrome's WebOTP API reads the same last line, so one message format serves both platforms.
The OTP Input puts the token on its first slot and sets the others to off. Its slots have no maxLength, so an inserted code arrives in one change event, which the component treats like a paste and spreads across the slots. A tap on an empty row always lands on the first slot, so an autofilled code starts where it should.
Why does React OTP input autofill fail silently?
Autofill fails silently because a missed condition is not an error: the keyboard shows no suggestion and nothing reaches the console. The cause sits in the markup, the message or the test setup. The ones we check, in order:
- Each slot has
maxLength={1}. The limit caps whatever a person inserts, a tap on the suggested code included, so only the first digit lands. Drop the limit and filter in the change handler. - The token is missing or overridden. A wrapper that does not forward
autoComplete, or anautocomplete="off"someone added to stop a different autofill. MDN describesoffas "the browser is not permitted to automatically enter or select a value for this field." - The message is bound to another domain. A code that ends in
@example.com #123456will not be offered on a preview deployment or a staging host on another domain. Make the domain in the message a per-environment setting. - You are testing on Android with the attribute alone. Chrome documents the WebOTP API for this; see the next section.
- The listener started late, or the page is on plain HTTP. Chrome's WebOTP guide (opens in a new tab) says the message "must be sent after
navigator.credentials.get()is called", so show the code step as soon as the send request starts, not after a redirect. The API also exists only in secure contexts, so opening your dev server from a phone athttp://192.168.1.20:3000turns the feature check false. Use an HTTPS tunnel. - The code does not fit the slots. An 8 character code sent to a 6 slot input loses its last two characters, and a code with letters loses its letters when
typeis"numbers". Matchlengthandtypeto what your server sends. - You tested in an emulator or a headless browser. The input-otp README (opens in a new tab) notes that SMS autofill "cannot be covered headlessly". Test on a real phone with a real message.
How do I read the code with the WebOTP API on Android?
Call navigator.credentials.get({ otp: { transport: ["sms"] } }) when the code screen mounts, abort it on unmount, and write the code into the input's value. When a matching message arrives, Chrome asks the person to confirm in a bottom sheet, then resolves the promise with the code. Feature-detect it: MDN marks the API as experimental and not Baseline, and Safari relies on the attribute instead. Save this as hooks/use-web-otp.ts:
The cast is there because TypeScript's DOM types lack the otp option, and useEffectEvent needs React 19.2, which the Next.js 16 App Router uses. Each get() call resolves once, so attempt restarts the listener after a resend. Inside a cross-origin iframe the API also needs the otp-credentials permission, per MDN's WebOTP page (opens in a new tab).
How do I validate an OTP input with React Hook Form and zod?
Wrap it in Controller and validate the joined string with zod. A React Hook Form OTP input needs Controller because register reads and resets native inputs through a ref, while the OTP Input is a row of inputs with one string value. For the label, hint and error, put it in a Field with labelAs="span" and let FieldControl pass the ids to the row: the group gets aria-labelledby and aria-describedby, and a click on the label focuses the first slot.
The full verify step uses the WebOTP hook above, a resend timer and a Button for each action. Install what it imports (useCooldown is a Pro item):
The component takes verify and resend as props, so a Next.js page can pass two Server Actions:
Three details worth copying:
reValidateMode: "onSubmit"stops "Enter the 6 digit code." from flashing during a retype, andclearErrorsinonChangehides the old message on the first keystroke.- A wrong code clears the slots and focuses the first one.
field.refwould land on the row's wrapperdiv, which cannot take focus, so the example finds the first slot by the Field's id. - Without React Hook Form, in a form that posts to a Server Action, render
<input type="hidden" name="code" value={code} />next to the row. The slots have noname, soFormDatawould not see them.
How do I add a resend timer to an OTP screen?
Lock the resend button for a fixed wait after every send, and store when the wait ends instead of counting a number down. A setInterval counter drifts when the phone locks or the tab sleeps, and a reload hands out a fresh button. The useCooldown hook keeps the end time, ticks once a second aligned to it, survives a reload with persistKey, and with autoStart opens the screen already waiting, since the first code was just sent.
$ npx wingo-ui@latest add use-cooldownThe hook returns remaining, active, progress for a ring and a label such as "0:42" for the button. The button only protects honest users: anyone can call your send endpoint directly, so enforce the same wait and a daily cap on the server, per phone number and IP address. useCooldown is part of Wingo UI Pro; if you write your own, keep the end timestamp in state and compute the seconds left from Date.now() on each tick.
Should you use the shadcn OTP input instead?
Use the shadcn OTP input (opens in a new tab) when you already use shadcn/ui, want a separator between two groups of three, or want the code in one real input. Use Wingo UI's OTP Input for built-in success and error states, touch sizing and a single element configured by props. The shadcn version is built on input-otp, which renders one text input, makes it invisible and draws the slots from its state; ours renders one input per slot. Both handle paste and SMS autofill. As of October 2026:
The input-otp README (opens in a new tab) also lists what a single input keeps from a native text field: undo, select-all, a partial paste into the middle of a half-filled code, and room for password manager badges. Our slots handle paste, Backspace and the arrow keys in code, but undo and select-all stop at a single slot.
How do I add the OTP input to a Next.js project?
Run the add command in a Next.js project with Tailwind CSS v4. The CLI copies the source files into your project and installs the npm packages they import:
The OTP Input docs have the playground and every prop. To start from a finished screen instead, the Login Page block ships this flow inside a sign-in page: a code view, a resend timer and a 60 second lock after three wrong codes. The Login Page and useCooldown are part of Wingo UI Pro, which costs $8 a month, $80 a year or $150 once as of October 2026 (Wingo UI pricing). For the rest of a form, read the React form components guide; more guides live in the component guides category.
FAQ
How do I autofill an OTP from SMS in React?
Put autocomplete="one-time-code" on the input that receives the code, leave out maxLength={1}, and end the text message with a line like "@example.com #123456". On Android, also call the WebOTP API, because Chrome documents origin-bound codes through that API instead of the attribute.
Why does OTP autofill only fill the first box?
Each box has a maxLength of 1, so the browser cuts the inserted code to one character before your change handler runs. Remove the limit, filter the characters yourself and spread a change of several characters across the boxes, the same way you handle a paste.
Does autocomplete one-time-code work in Chrome on Android?
Do not count on it. web.dev lists the attribute for Safari on iOS, iPadOS and macOS, and lists Chrome, Opera and Vivaldi on Android under the WebOTP API instead. Keep the attribute for Safari and add a navigator.credentials.get call with the sms transport for Android.
How do I use an OTP input with react-hook-form?
Wrap it in Controller, pass field.value and field.onChange, and call handleSubmit from onComplete so the last digit submits the form. Validate with a zod rule such as a six-digit regex, and clear the error as soon as the person types again.
Is the Wingo UI OTP input free?
Yes. The OTP Input, Field and Button are free and install with npx wingo-ui@latest add otp-input field button. The useCooldown hook and the Login Page block are part of Wingo UI Pro.
- OTP
- Authentication
- React Hook Form
- Mobile UI
- Accessibility