# OTP Input

A one-time-code input with per-character slots and paste support.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/otp-input.json
```

[Registry JSON](https://mwui.vercel.app/r/otp-input.json)

## Preview

```tsx
import * as React from "react";

import { OtpInput } from "@/components/ui/otp-input";

export function Preview() {
  const [code, setCode] = React.useState("");
  const [status, setStatus] = React.useState("Waiting for a code.");

  return (
    <div className="flex w-full max-w-sm flex-col gap-6 text-sm">
      <div className="flex flex-col gap-2">
        <p className="font-medium">Six digits, grouped</p>
        <OtpInput
          value={code}
          onValueChange={setCode}
          groupSize={3}
          onComplete={(value) => setStatus(`Submitted ${value}.`)}
        />
        <p className="text-xs text-muted-foreground">{status}</p>
      </div>

      <div className="flex flex-col gap-2">
        <p className="font-medium">Alphanumeric, masked</p>
        <OtpInput length={4} pattern="alphanumeric" mask aria-label="Backup code" />
      </div>
    </div>
  );
}
```


## Source

### ui/otp-input.tsx

```tsx
"use client";

import * as React from "react";

import { useControllableState } from "@/hooks/use-controllable-state";
import { cn } from "@/lib/utils";

type OtpInputPattern = "numeric" | "alphanumeric" | RegExp;

type OtpInputProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange" | "onPaste" | "value"
> & {
  length?: number;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  onComplete?: (value: string) => void;
  pattern?: OtpInputPattern;
  groupSize?: number;
  separator?: React.ReactNode;
  mask?: boolean;
  disabled?: boolean;
  autoFocus?: boolean;
  name?: string;
  "aria-label"?: string;
};

function OtpInput({
  length = 6,
  value,
  defaultValue = "",
  onValueChange,
  onComplete,
  pattern = "numeric",
  groupSize,
  separator = "-",
  mask = false,
  disabled = false,
  autoFocus = false,
  name,
  className,
  "aria-label": ariaLabel = "One-time code",
  ...props
}: OtpInputProps) {
  const [code, setCode] = useControllableState<string>({
    value,
    defaultValue,
    onChange: onValueChange,
  });

  const [focused, setFocused] = React.useState(false);
  const [caret, setCaret] = React.useState({ start: 0, end: 0 });

  const inputRef = React.useRef<HTMLInputElement>(null);
  const allowed = React.useMemo(() => toRegExp(pattern), [pattern]);

  React.useEffect(() => {
    if (autoFocus) inputRef.current?.focus();
  }, [autoFocus]);

  const syncCaret = () => {
    const input = inputRef.current;
    if (!input) return;

    setCaret({ start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 });
  };

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const next = Array.from(event.target.value)
      .filter((character) => allowed.test(character))
      .join("")
      .slice(0, length);

    setCode(next);
    if (next.length === length && next !== code) onComplete?.(next);
  };

  const isRangeSelection = caret.end - caret.start > 1;
  const activeIndex = Math.min(caret.start, length - 1);

  return (
    <div
      data-slot="otp-input"
      data-disabled={disabled || undefined}
      className={cn(
        "relative inline-flex w-fit items-center gap-1.5 data-disabled:pointer-events-none data-disabled:opacity-50",
        className,
      )}
      {...props}
    >
      <input
        ref={inputRef}
        value={code}
        name={name}
        disabled={disabled}
        inputMode={pattern === "numeric" ? "numeric" : "text"}
        autoComplete="one-time-code"
        aria-label={ariaLabel}
        maxLength={length}
        className="absolute inset-0 z-10 w-full bg-transparent text-transparent caret-transparent opacity-0 outline-none"
        onChange={handleChange}
        onSelect={syncCaret}
        onKeyUp={syncCaret}
        onPointerUp={syncCaret}
        onFocus={() => {
          setFocused(true);
          syncCaret();
        }}
        onBlur={() => setFocused(false)}
      />

      {Array.from({ length }, (_, index) => {
        const character = code[index];
        const active =
          focused &&
          !disabled &&
          (isRangeSelection ? index >= caret.start && index < caret.end : index === activeIndex);

        return (
          <React.Fragment key={index}>
            {groupSize && index > 0 && index % groupSize === 0 ? (
              <span aria-hidden="true" className="px-0.5 text-muted-foreground">
                {separator}
              </span>
            ) : null}

            <div
              data-slot="otp-input-slot"
              data-active={active || undefined}
              data-filled={character !== undefined || undefined}
              aria-hidden="true"
              className={cn(
                "relative flex h-10 w-9 items-center justify-center rounded-lg border border-input bg-transparent text-sm font-medium transition-colors",
                "data-active:border-ring data-active:ring-3 data-active:ring-ring/50",
              )}
            >
              {character === undefined ? null : mask ? (
                <span className="size-2 rounded-full bg-foreground" />
              ) : (
                character
              )}

              {active && character === undefined && !isRangeSelection ? (
                <span className="absolute h-4 w-px animate-caret-blink bg-foreground" />
              ) : null}
            </div>
          </React.Fragment>
        );
      })}
    </div>
  );
}

function toRegExp(pattern: OtpInputPattern): RegExp {
  if (pattern instanceof RegExp) return pattern;
  if (pattern === "alphanumeric") return /^[a-z0-9]$/iu;

  return /^[0-9]$/u;
}

export { OtpInput, type OtpInputPattern, type OtpInputProps };
```



## Usage

Verification codes deserve one box per character, and every implementation that renders one real
`<input>` per box breaks something: paste fills only the first slot, iOS SMS autofill does not fire,
backspace across boundaries gets weird, and the whole thing is unusable to a screen reader.

This one is a single input — transparent, stretched across the field — with the boxes drawn behind
it. The platform keeps doing its job.

```tsx
import { OtpInput } from "@/components/ui/otp-input";

export function VerifyForm() {
  return <OtpInput length={6} onComplete={(code) => verify(code)} />;
}
```

`onComplete` fires as soon as the last slot fills, which is the moment to submit — asking someone to
press a button after typing the sixth digit is a step nobody needs.

## What you get for free

- **Paste** puts the whole code in, from anywhere in the field.
- **SMS autofill** works: `autocomplete="one-time-code"` on a real input is what iOS and Android look
  for. Splitting into several inputs is exactly what disables it.
- **Selection** behaves — shift-arrow, select-all, drag — and the highlighted slots follow it.
- **Password managers and IME** see one ordinary text field.

## Format

```tsx
<OtpInput length={6} groupSize={3} />
<OtpInput length={4} pattern="alphanumeric" />
<OtpInput length={6} mask />
```

`pattern` accepts `"numeric"` (the default), `"alphanumeric"`, or your own single-character `RegExp`
— characters that fail it are dropped as they arrive, including from a paste, so a copied code with
spaces or dashes still lands correctly.

`groupSize` inserts a separator every N slots. `mask` renders dots, for codes that act as secrets
rather than transcriptions.

## Props

| Prop           | Description                                                          |
| -------------- | -------------------------------------------------------------------- |
| `length`       | Number of slots. Defaults to `6`.                                     |
| `value` / `defaultValue` / `onValueChange` | Controlled or uncontrolled string.         |
| `onComplete`   | Called with the full code once the last slot fills.                   |
| `pattern`      | `"numeric"`, `"alphanumeric"`, or a single-character `RegExp`.         |
| `groupSize`    | Insert `separator` every N slots.                                     |
| `separator`    | Node rendered between groups. Defaults to `"-"`.                      |
| `mask`         | Render filled slots as dots.                                          |
| `name`         | Posts the code in a plain HTML form.                                  |
| `aria-label`   | Labels the field. Defaults to `"One-time code"`.                      |

## Accessibility

The slots are `aria-hidden` decoration; the input underneath carries the label and the value, so
assistive technology reads a single labelled text field rather than announcing six unlabelled boxes.
The blinking caret is drawn on the active empty slot, since the real caret is hidden.

