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

> How to build a React OTP input that takes paste and SMS autofill, why autocomplete one-time-code fails silently, and how to add zod checks and a resend timer.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/react-otp-input

## 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](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/mobile-first-react-components).

> Live demo (OTP Input): 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 at [OTP Input](https://wingo-ui.com/components/otp-input) and install it with `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](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete) 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](https://web.dev/articles/sms-otp-form) (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](https://developer.apple.com/documentation/security/enabling-autofill-for-domain-bound-sms-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:

```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](https://developer.chrome.com/docs/identity/web-apis/web-otp) 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](https://github.com/guilhermerodz/input-otp) 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](https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API).

## 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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/use-cooldown) 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): Press Resend code: the button locks and counts down from 0:30 while the ring fills, then turns back on by itself. Try it at [useCooldown](https://wingo-ui.com/components/use-cooldown) and install it with `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](https://ui.shadcn.com/docs/components/input-otp) 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](https://github.com/guilhermerodz/input-otp) 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](https://wingo-ui.com/components/otp-input) have the playground and every prop. To start from a finished screen instead, the [Login Page](https://wingo-ui.com/components/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](https://wingo-ui.com/pricing)). For the rest of a form, read the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide); more guides live in the [component guides category](https://wingo-ui.com/blog/category/components).

## Components in this post

- [OTP Input](https://wingo-ui.com/components/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](https://wingo-ui.com/components/use-cooldown) (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](https://wingo-ui.com/components/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.

---

Source: https://wingo-ui.com/blog/react-otp-input
