Input OTP

One-time-password / verification-code entry built on the input-otp library. Renders N character slots with active/caret state and auto-advance on type.

6-digit code (with separator)

Enter your one-time password.

4-digit PIN

Disabled

1
2
3
4
5
6

Installation

pnpm
pnpm dlx @hex-core/cli add input-otp

With error state

Verification status surfaced via a small state machine. On submit, the code is validated; an invalid result paints each slot with a destructive ring and surfaces an inline error message that screen readers announce.

tsx
import { useState } from "react";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { Button } from "@/components/ui/button";

type Verified = "idle" | "valid" | "invalid";

const EXPECTED = "123456";

export function VerifyOtp() {
  const [value, setValue] = useState("");
  const [verified, setVerified] = useState<Verified>("idle");
  const isInvalid = verified === "invalid";

  return (
    <form
      className="grid gap-3"
      onSubmit={(e) => {
        e.preventDefault();
        setVerified(value === EXPECTED ? "valid" : "invalid");
      }}
    >
      <InputOTP
        maxLength={6}
        pattern={REGEXP_ONLY_DIGITS}
        value={value}
        onChange={(next) => {
          setValue(next);
          if (isInvalid) setVerified("idle");
        }}
        aria-invalid={isInvalid}
        aria-describedby={isInvalid ? "otp-error" : undefined}
      >
        <InputOTPGroup>
          {Array.from({ length: 6 }, (_, index) => (
            <InputOTPSlot
              key={index}
              index={index}
              className={isInvalid ? "border-destructive ring-destructive/50" : undefined}
            />
          ))}
        </InputOTPGroup>
      </InputOTP>
      {verified === "invalid" && (
        <p id="otp-error" role="alert" className="text-sm font-medium text-destructive">
          That code is incorrect. Try again or request a new one.
        </p>
      )}
      {verified === "valid" && (
        <p className="text-sm font-medium text-emerald-600">Verified — redirecting…</p>
      )}
      <Button type="submit" disabled={value.length !== 6} className="justify-self-start">
        Verify
      </Button>
    </form>
  );
}

Auto-submit on completion

Canonical 2FA UX — fire the verify call as soon as the user finishes typing the last digit using InputOTP's first-class onComplete prop. A small status row keeps the user informed during the network round-trip.

tsx
import { useState } from "react";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";

type Status = "idle" | "verifying" | "success" | "error";

const MAX_LENGTH = 6;

export function AutoSubmitOtp() {
  const [value, setValue] = useState("");
  const [status, setStatus] = useState<Status>("idle");

  return (
    <div className="grid gap-2">
      <InputOTP
        maxLength={MAX_LENGTH}
        pattern={REGEXP_ONLY_DIGITS}
        value={value}
        onChange={setValue}
        onComplete={async (code) => {
          setStatus("verifying");
          try {
            const res = await fetch("/api/auth/verify", {
              method: "POST",
              body: JSON.stringify({ code }),
            });
            setStatus(res.ok ? "success" : "error");
          } catch {
            setStatus("error");
          }
        }}
        disabled={status === "verifying" || status === "success"}
      >
        <InputOTPGroup>
          {Array.from({ length: MAX_LENGTH }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
      <p className="text-sm text-muted-foreground" aria-live="polite">
        {status === "verifying" && "Verifying…"}
        {status === "success" && "Code accepted."}
        {status === "error" && "That code didn't work. Try again."}
        {status === "idle" && "Enter the 6-digit code from your authenticator."}
      </p>
    </div>
  );
}

API Reference

PropTypeDefaultDescription
maxLengthrequired
numberTotal number of slots (typically 4–8 for OTPs)
value
stringControlled value — the current entered string
onChange
functionCallback fired as the user types: (value: string) => void
onComplete
functionCalled when all slots are filled (useful to auto-submit)
pattern
stringRegex to restrict input (use REGEXP_ONLY_DIGITS, REGEXP_ONLY_CHARS, or REGEXP_ONLY_DIGITS_AND_CHARS)
disabled
booleanfalseDisable the whole input

AI Guidance

When to use

Use for one-time password, email verification code, 2FA code, or any fixed-length code entry. Auto-advances on type, supports paste of the full code, and supports regex validation.

When not to use

Don't use for variable-length codes (use a plain Input). Don't use for passwords (use Input type='password'). Don't use for open-ended short text — the slot UI implies a code.

Common mistakes

  • Forgetting to render maxLength slots — the underlying input's maxLength won't match the visible UI
  • Using pattern without importing one of the REGEXP_ONLY_* constants from 'input-otp'
  • Wrapping the whole thing in a <form> without a submit handler — onComplete is often a better auto-submit hook
  • Overriding slot className in a way that removes the first/last border-radius rules

Accessibility

input-otp manages a single hidden <input> so screen readers hear one field of N characters. Each slot is a visual representation. The active slot gets a focus ring via the ring token.

Related components

Token budget: 700