WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Component guides
  4. React OTP Input: Paste, SMS Autofill and a Resend Timer
  1. Blog
  2. Component guides
  3. React OTP Input: Paste, SMS Autofill and a Resend Timer
Component guides
Component guides

React OTP Input: Paste, SMS Autofill and a Resend Timer

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 11 min read

On this page

0%
  1. What should a React OTP input handle?
  2. How does autocomplete one-time-code autofill work?
  3. Why does React OTP input autofill fail silently?
  4. How do I read the code with the WebOTP API on Android?
  5. How do I validate an OTP input with React Hook Form and zod?
  6. How do I add a resend timer to an OTP screen?
  7. Should you use the shadcn OTP input instead?
  8. How do I add the OTP input to a Next.js project?
  9. FAQ
    1. How do I autofill an OTP from SMS in React?
    2. Why does OTP autofill only fill the first box?
    3. Does autocomplete one-time-code work in Chrome on Android?
    4. How do I use an OTP input with react-hook-form?
    5. Is the Wingo UI OTP input free?

TL;DR

A React OTP input should put autocomplete="one-time-code" on the slot that receives the code, leave out maxLength={1}, spread a pasted or autofilled code across every slot, and clear a slot before Backspace steps back; the free Wingo UI OTP Input does all of that. Android needs one more piece, the WebOTP API, because Chrome documents SMS codes for the web through that API instead of the attribute. For the resend timer, lock the button for a fixed wait after each send, store when the wait ends, and enforce the same wait on the server.

Published Oct 9, 2026

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 for type="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" (slotLabel translates 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:

bash
npx wingo-ui@latest add otp-input
tsx
"use client";
import { useState } from "react";
import OtpInput, { type OtpStatus } from "@/components/ui/otp-input";
export function CodeStep({ check }: { check: (code: string) => Promise<boolean> }) {
const [status, setStatus] = useState<OtpStatus>("idle");
return (
<OtpInput
role="group"
aria-label="Verification code"
length={6}
autoFocus
status={status}
onChange={() => setStatus("idle")}
onComplete={async (code) => setStatus((await check(code)) ? "success" : "error")}
/>
);
}

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.

Type 123456 to see the success ring, or any other code to see the error shake. Then clear it and paste a code with a space in it, like 123 456
$ npx wingo-ui@latest add otp-input
FreeOTP Input docs

How 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:

text
Your Example code is 123456.
@example.com #123456

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:

  1. 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.
  2. The token is missing or overridden. A wrapper that does not forward autoComplete, or an autocomplete="off" someone added to stop a different autofill. MDN describes off as "the browser is not permitted to automatically enter or select a value for this field."
  3. The message is bound to another domain. A code that ends in @example.com #123456 will not be offered on a preview deployment or a staging host on another domain. Make the domain in the message a per-environment setting.
  4. You are testing on Android with the attribute alone. Chrome documents the WebOTP API for this; see the next section.
  5. 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 at http://192.168.1.20:3000 turns the feature check false. Use an HTTPS tunnel.
  6. 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 type is "numbers". Match length and type to what your server sends.
  7. 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:

tsx
import { useEffect, useEffectEvent } from "react";
type OtpCredential = Credential & { code: string };
// android chrome: waits for an sms whose last line is "@your-domain #123456" and hands over the code.
// mount it before the message goes out; bump `attempt` to listen again after a resend
export function useWebOtp(onCode: (code: string) => void, attempt = 0) {
const receive = useEffectEvent(onCode);
useEffect(() => {
if (!("OTPCredential" in window)) return;
const controller = new AbortController();
navigator.credentials
.get({ otp: { transport: ["sms"] }, signal: controller.signal } as CredentialRequestOptions)
.then((credential) => {
const code = (credential as OtpCredential | null)?.code;
if (code) receive(code);
})
.catch(() => {
// aborted, dismissed or no message: typing still works
});
return () => controller.abort();
}, [attempt]);
}

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):

bash
npm install react-hook-form @hookform/resolvers zod
npx wingo-ui@latest add otp-input field button use-cooldown

The component takes verify and resend as props, so a Next.js page can pass two Server Actions:

tsx
"use client";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Field, FieldControl } from "@/components/ui/field";
import OtpInput, { type OtpStatus } from "@/components/ui/otp-input";
import { useCooldown } from "@/hooks/use-cooldown";
import { useWebOtp } from "@/hooks/use-web-otp";
const schema = z.object({
code: z.string().regex(/^\d{6}$/, "Enter the 6 digit code."),
});
type Props = {
phone: string;
verify: (code: string) => Promise<boolean>;
resend: () => Promise<void>;
};
export function VerifyCode({ phone, verify, resend }: Props) {
const [status, setStatus] = useState<OtpStatus>("idle");
const [sent, setSent] = useState(0);
const cooldown = useCooldown({ seconds: 30, autoStart: true, persistKey: "verify-resend" });
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { code: "" },
reValidateMode: "onSubmit",
});
const submit = form.handleSubmit(async ({ code }) => {
if (await verify(code)) {
setStatus("success");
return;
}
setStatus("error");
form.setValue("code", "");
form.setError("code", { message: "That code did not work. Check the latest text message." });
document.querySelector<HTMLInputElement>("#verify-code input")?.focus();
});
// android chrome: the code arrives through WebOTP, fills the slots and submits
useWebOtp((code) => {
form.setValue("code", code);
void submit();
}, sent);
return (
<form noValidate onSubmit={submit} className="flex flex-col gap-4">
<Controller
control={form.control}
name="code"
render={({ field, fieldState }) => (
<Field
id="verify-code"
label="Verification code"
labelAs="span"
description={`We sent a 6 digit code to ${phone}.`}
error={fieldState.error?.message}
>
<FieldControl>
<OtpInput
role="group"
autoFocus
value={field.value}
status={status}
onChange={(code) => {
field.onChange(code);
setStatus("idle");
form.clearErrors("code");
}}
// the last digit submits, whether it was typed, pasted or autofilled
onComplete={() => {
if (!form.formState.isSubmitting) void submit();
}}
/>
</FieldControl>
</Field>
)}
/>
<Button type="submit" fullWidth loading={form.formState.isSubmitting}>
Verify
</Button>
<Button
type="button"
variant="ghost"
fullWidth
disabled={cooldown.active}
onClick={() => {
// the wait starts before the request, so a double tap cannot send two messages
cooldown.start();
setSent((count) => count + 1);
void resend();
}}
>
<span className="tabular-nums">
{cooldown.active ? `Resend code in ${cooldown.label}` : "Resend code"}
</span>
</Button>
</form>
);
}

Three details worth copying:

  • reValidateMode: "onSubmit" stops "Enter the 6 digit code." from flashing during a retype, and clearErrors in onChange hides the old message on the first keystroke.
  • A wrong code clears the slots and focuses the first one. field.ref would land on the row's wrapper div, 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 no name, so FormData would 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.

Press Resend code: the button locks and counts down from 0:30 while the ring fills, then turns back on by itself
$ npx wingo-ui@latest add use-cooldown
ProuseCooldown docs

The 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:

shadcn/ui Input OTPWingo UI OTP Input
Under the hoodinput-otp, one invisible inputOne input per slot
APIComposed: InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparatorOne element with props (length, type, size, status, mask, colors)
Accepted characterspattern regex, such as REGEXP_ONLY_DIGITStype: numbers, letters or both
Separator between groupsYes, InputOTPSeparatorNo
Form valueOne input with a name, so FormData sees the codeNo name on the slots; add a hidden input
Feedback statesError styling through aria-invalid on the slotsRed ring and a shake for errors, a green ring for success, reduced motion respected
Installnpx shadcn@latest add input-otpnpx wingo-ui@latest add otp-input

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:

bash
npx wingo-ui@latest add otp-input field button

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.

Components in this post

  • OTP Input

    A one-time-code input whose characters roll into place behind a caret that slides from slot to slot.

    Free
  • useCooldown

    A resend or retry timer: wait N seconds before a code can be sent again, exact after the tab sleeps and kept across reloads.

    Pro
  • Field

    The wrapper every form control shares: label, hint, animated error and counter, fieldsets, and the one box recipe all inputs use.

    Free

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

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

React Form Components: Accessible Fields, Zod and Mobile

React form components that share one anatomy (label, hint, error, counter), plug into react-hook-form and zod, and behave right on a phone, with full code.

SRSerban Rusu·Oct 9, 2026·21 min read
Component guides

How to Update shadcn Components Without Losing Edits

Update shadcn components without losing edits: what shadcn diff and add --diff show, why a 2-way diff hides whose change is whose, and how a 3-way merge works.

SRSerban Rusu·Oct 9, 2026·10 min read
Component guides

Romanian CUI Validation in TypeScript, Zod and React

Romanian CUI validation in TypeScript: the 753217532 check digit step by step, the RO prefix, a zod rule, a React field and a company lookup at ANAF.

SRSerban Rusu·Oct 9, 2026·11 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI