# OTP Input in React: Paste, Autofill and one-time-code That Work

> Use one real input with autocomplete="one-time-code", inputMode="numeric" and no maxLength, drawn as six boxes. Filter to digits and slice in onChange so a pasted "123-456" keeps all six digits, and call onComplete from an effect so the form submits the clean value.

- URL: https://qodesign.dev/blog/otp-input-react-paste-autofill
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Next.js fixes
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

An OTP input in React should be one real `<input>` with `autoComplete="one-time-code"`, `inputMode="numeric"` and no `maxLength`, drawn as six boxes. That single input is what lets paste, SMS autofill and password managers work without extra code. Six separate inputs look the same, but then you have to write focus-jumping, paste-splitting and backspace logic yourself, and the autofilled code arrives in whichever box happens to have focus.

I build auth screens for qodesign and got this wrong once. Below is the version I use now, about 70 lines of React and Tailwind CSS, with each behavior checked in headless Chromium on a Next.js production build.

## Why one input beats six

The six-input pattern is what most generated code gives you, and what v0 or Cursor will often write if you just ask for "an OTP input". It needs:

1. Moving focus to the next box on every keystroke.
2. Moving focus back on Backspace.
3. Splitting a pasted "123456" across six boxes.
4. Handling autofill, which fills one field with the whole code.
5. Joining the six values back into one string for your form.

With one input, the browser already does all of that. The boxes are just a picture of the input's value. Screen readers hear one text field called "Verification code, 6 digits" instead of six unlabeled ones, and a plain form submit sends the code under one name.

This is also how the `input-otp` library works, and that's the library behind shadcn/ui's InputOTP. If you already use shadcn, that's a fine choice. The component below is the same idea with no dependency, so you can read every line.

## The OtpInput component

Save this as `components/OtpInput.tsx`. It's a client component, so it works in the App Router and in Vite.

```tsx
"use client";

import { useEffect, useRef, useState } from "react";

type Props = {
  length?: number;
  onComplete: (code: string) => void;
  disabled?: boolean;
};

export function OtpInput({ length = 6, onComplete, disabled }: Props) {
  const [value, setValue] = useState("");
  const [focused, setFocused] = useState(false);
  const lastSent = useRef("");

  // Runs after React has written the clean value back to the input,
  // so a form submitted from onComplete sends "123456", not "123 456"
  useEffect(() => {
    if (value.length < length) {
      lastSent.current = "";
      return;
    }
    if (value !== lastSent.current) {
      lastSent.current = value;
      onComplete(value);
    }
  }, [value, length, onComplete]);

  const active = Math.min(value.length, length - 1);

  return (
    <div className="relative w-fit">
      <input
        value={value}
        // Keep digits only: covers paste ("123 456", "123-456") and autofill
        onChange={(e) => setValue(e.target.value.replace(/\D/g, "").slice(0, length))}
        onFocus={() => setFocused(true)}
        onBlur={() => setFocused(false)}
        disabled={disabled}
        name="otp"
        aria-label={`Verification code, ${length} digits`}
        autoComplete="one-time-code"
        inputMode="numeric"
        pattern={`\\d{${length}}`}
        spellCheck={false}
        className="absolute inset-0 h-full w-full cursor-text opacity-0 disabled:cursor-not-allowed"
      />
      <div aria-hidden="true" className="flex gap-2">
        {Array.from({ length }, (_, i) => (
          <div
            key={i}
            data-slot={i}
            className={[
              "grid h-14 w-11 place-items-center rounded-xl border text-2xl font-semibold tabular-nums transition-colors",
              focused && i === active ? "border-lime-400 ring-2 ring-lime-400/30" : "border-zinc-700",
              disabled ? "opacity-50" : "",
            ].join(" ")}
          >
            {value[i] ?? ""}
          </div>
        ))}
      </div>
    </div>
  );
}
```

The input is stretched over the boxes with `opacity-0`, so a tap or click anywhere on the boxes lands in the input and opens the keyboard. The boxes are `aria-hidden` so they don't get read twice.

## The three attributes that matter

`autoComplete="one-time-code"` is the standard autocomplete token for one-time codes. Safari reads it to suggest a code from Messages or Mail above the keyboard, and password managers use it too.

`inputMode="numeric"` opens the number pad on phones while keeping the field a text input. I'd avoid `type="number"`: it adds spinner arrows, accepts `e` and `-`, and drops leading zeros if you ever read it as a number. A code like `012345` has to stay a string.

`pattern` gives the browser's built-in form validation a rule to check: exactly six digits, written `\d{6}`.

## Don't set maxLength on an OTP input

This one surprised me. My first version had `maxLength={6}`. When I pasted `987-654` in Chromium, the input ended up with `98765`. The browser cuts pasted text to `maxLength` before `onChange` runs, so the dash used up a character and the last digit was gone before my digit filter saw it. Inserting `111 222` the way an autofill would gave `11122`.

Codes come with spaces and dashes all the time: "123 456" in an email, "123-456" in an SMS. So the component has no `maxLength`. It filters to digits and then slices to the length in `onChange`, which gives `987654` and `111222`. Typing a seventh digit still does nothing, because the slice drops it.

## Submitting when the last digit lands

Most people expect the form to submit as soon as the sixth digit appears. Here is a form that does that with a Server Action and React 19's `useActionState`. First the action, in `app/verify/actions.ts`:

```ts
"use server";

export type VerifyState = { ok: boolean; error?: string };

export async function verifyCode(_prev: VerifyState, formData: FormData): Promise<VerifyState> {
  const code = String(formData.get("otp") ?? "");
  if (!/^\d{6}$/.test(code)) return { ok: false, error: "Enter all 6 digits." };
  // Replace with your real check (Supabase verifyOtp, your own table, etc.)
  const valid = code === "123456";
  return valid ? { ok: true } : { ok: false, error: "That code didn't work. Try again." };
}
```

Then the form, in `app/verify/VerifyForm.tsx`:

```tsx
"use client";

import { useActionState, useRef } from "react";
import { OtpInput } from "@/components/OtpInput";
import { verifyCode, type VerifyState } from "./actions";

export function VerifyForm() {
  const formRef = useRef<HTMLFormElement>(null);
  const [state, action, pending] = useActionState<VerifyState, FormData>(verifyCode, { ok: false });

  if (state.ok) return <p role="status">Verified.</p>;

  return (
    <form ref={formRef} action={action} className="space-y-3">
      <OtpInput onComplete={() => formRef.current?.requestSubmit()} />
      <p role="alert" className="min-h-5 text-sm text-red-400">
        {state.error}
      </p>
      <button type="submit" disabled={pending} className="rounded-lg bg-lime-400 px-4 py-2 text-zinc-950">
        {pending ? "Checking..." : "Verify"}
      </button>
    </form>
  );
}
```

Two details came out of testing this.

First, `onComplete` runs from a `useEffect`, not from inside `onChange`. My first version called it inside `onChange`, and pasting `123 456` submitted the raw text `123 456`, because React hadn't yet written the cleaned value back to the input when `requestSubmit()` read the form. Running it after the render sends `123456`.

Second, I don't disable the input while the code is being checked. A disabled input loses focus. In my test, after a wrong code the focus had moved to `<body>`, so the user had to tap the field again before retyping. With only the button disabled, focus stays in the input and the error is read out through `role="alert"`.

The `lastSent` ref makes `onComplete` fire once per complete code. If the user deletes a digit and types it again, it fires again, which is what you want after a wrong code.

## What I tested

All of this ran on `next build` and `next start` (Next.js 15.5, React 19.1, Tailwind CSS v4.3), driven by Playwright in headless Chromium, with `tsc --strict` passing:

- Clicking the third box focused the input.
- Typing `12a3` gave `123`, and the highlight moved to the fourth box.
- Backspace removed one digit.
- A seventh digit was ignored.
- Ctrl+V with `987-654` on the clipboard gave `987654` and fired `onComplete`.
- Inserting `111 222` as one edit, the way autofill does, gave `111222`.
- A wrong code showed the error and kept focus. Pasting `123 456` then submitted `123456` and showed "Verified."
- The accessibility tree showed one textbox and no boxes.
- No console errors.

What I couldn't test here is a real iPhone suggesting a code from Messages. The input uses the attribute and the single-field shape that feature is built for, but try it on a device before you ship.

## Optional: domain-bound SMS codes

If you send codes by SMS, end the message with a line like `@yourdomain.com #123456`. That format comes from the origin-bound one-time codes proposal. Safari uses it to offer the code only on your domain, and Chrome on Android uses it for the WebOTP API, which can read the code without the user copying it. It's a change to your SMS text, not your component, so it's cheap to add. I haven't tested WebOTP on a phone for this post.

## The server side still matters

The input is the easy half. On the server, store a hash of the code with an expiry of a few minutes, limit attempts per user and per IP, and let a code be used once. If you use Supabase, Clerk or Auth.js email codes, they handle this for you and your form just passes the six digits along.

## A note on animation

A verification screen is a good place for a little motion, because the user is waiting anyway. If you want one that's already built, the [Step-Up OTP Gate](/components/otp-verify) on qodesign does the six-cell input with an arc that spins while the code checks and a dashboard that unlocks behind it. There's a [Next.js guide for it](/react/animated-otp-input), and some free pieces with full source at [/free](/free). For the login screen before it, I wrote up how to keep an [animated login form accessible](/blog/accessible-animated-login-form-react).

## FAQ

### How do I make an OTP input work with paste in React?

Use one input instead of six. In `onChange`, strip everything that isn't a digit and slice to the code length. Don't set `maxLength`, because the browser cuts pasted text like "123-456" to that length before your handler runs, and you lose the last digit.

### What does autocomplete="one-time-code" do?

It tells the browser and password managers that the field takes a one-time code. Safari uses it to suggest a code it found in Messages or Mail above the keyboard. It only works well on a single input that receives the whole code.

### Should an OTP input use type="number"?

No. Use `type="text"` (the default) with `inputMode="numeric"`. That opens the number pad on phones without the spinner, the `e` and `-` characters, or the risk of losing a leading zero.

### Is input-otp better than building my own?

`input-otp`, which shadcn/ui's InputOTP uses, takes the same single-input approach and covers more edge cases. Use it if you already have shadcn. Write your own when you want no dependency or a custom look, and keep the one-input structure either way.
