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

6 min read

TL;DR 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.

On this page
  1. Why one input beats six
  2. The OtpInput component
  3. The three attributes that matter
  4. Don't set maxLength on an OTP input
  5. Submitting when the last digit lands
  6. What I tested
  7. Optional: domain-bound SMS codes
  8. The server side still matters
  9. A note on animation
  10. FAQ
  11. How do I make an OTP input work with paste in React?
  12. What does autocomplete="one-time-code" do?
  13. Should an OTP input use type="number"?
  14. Is input-otp better than building my own?

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.

Step-Up OTP Gate running live. Arcs spin around six chunky cells, merge into one check, and the blurred dashboard behind them unlocks.

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 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, and some free pieces with full source at /free. For the login screen before it, I wrote up how to keep an animated login form accessible.

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.