DEV Community

Cover image for React OTP Input: Paste, SMS Autofill and a Resend Timer
Serban Dan Rusu for Wingo UI

Posted on Originally published at wingo-ui.com

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

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.

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:

npx wingo-ui@latest add otp-input
Enter fullscreen mode Exit fullscreen mode
"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")}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Live demo: OTP Input. The original article embeds an interactive demo here (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). Try it live on wingo-ui.com or open the OTP Input docs. Install: npx wingo-ui@latest add otp-input

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 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 (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, 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:

Your Example code is 123456.

@example.com #123456
Enter fullscreen mode Exit fullscreen mode

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

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]);
}
Enter fullscreen mode Exit fullscreen mode

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.

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

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

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

"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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Live demo: useCooldown. The original article embeds an interactive demo here (Press Resend code: the button locks and counts down from 0:30 while the ring fills, then turns back on by itself). Try it live on wingo-ui.com or open the useCooldown docs. Install: npx wingo-ui@latest add use-cooldown

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 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 OTP Wingo UI OTP Input
Under the hood input-otp, one invisible input One input per slot
API Composed: InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator One element with props (length, type, size, status, mask, colors)
Accepted characters pattern regex, such as REGEXP_ONLY_DIGITS type: numbers, letters or both
Separator between groups Yes, InputOTPSeparator No
Form value One input with a name, so FormData sees the code No name on the slots; add a hidden input
Feedback states Error styling through aria-invalid on the slots Red ring and a shake for errors, a green ring for success, reduced motion respected
Install npx shadcn@latest add input-otp npx wingo-ui@latest add otp-input

The input-otp README 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:

npx wingo-ui@latest add otp-input field button
Enter fullscreen mode Exit fullscreen mode

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 (Free): A one-time-code input whose characters roll into place behind a caret that slides from slot to slot. Install: npx wingo-ui@latest add otp-input
  • useCooldown (Pro): A resend or retry timer: wait N seconds before a code can be sent again, exact after the tab sleeps and kept across reloads. Install: npx wingo-ui@latest add use-cooldown
  • Field (Free): The wrapper every form control shares: label, hint, animated error and counter, fieldsets, and the one box recipe all inputs use. Install: npx wingo-ui@latest add field

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.


This article was originally published on the Wingo UI blog: React OTP Input: Paste, SMS Autofill and a Resend Timer. The original has the live, interactive demos and is kept up to date.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

The checklist usefully separates input behavior from delivery and rate limiting. I would add a cross-device acceptance test matrix: paste, iOS AutoFill, Android WebOTP, keyboard-only editing, and a resend attempt before the server cooldown expires. Recording the message-domain and environment for each run would make the silent autofill failures much easier to diagnose.