# Input OTP

An accessible single-input one-time-code control with visual cells.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```tsx
"use client";

import { useState } from "react";
import { InputOTP } from "@/components/ui/input-otp";

export function InputOtpDemo() {
  const [code, setCode] = useState("");
  return (
    <>
      <InputOTP
          aria-label={"Verification code"}
          value={code}
          onValueChange={setCode}
          onComplete={(value) => setCode(value)}
          length={6}
          type={"numeric"}
      />
    </>
  );
}
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

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

The default installation target is components/ui/input-otp.tsx. Review the CLI's overwrite prompt before replacing local customizations.

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install cn
```

## Usage [#usage]

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

<InputOTP aria-label="Verification code" length={6} onComplete={verifyCode} />;
```

Numeric input is the default. Set `type="alphanumeric"` for letters and digits.

## Examples [#examples]

Completion feedback reports that the code is ready without printing the code itself.

<ComponentPreview name="input-otp-demo" title="input-otp-demo.tsx">
  <InputOTPDemo />
</ComponentPreview>

### Optional Storybook Stories [#optional-storybook-stories]

Install Input OTP first, then run `npx shadcn@latest add https://vandor-ui.vercel.app/r/input-otp-stories.json`. Stories cover numeric/alphanumeric and partial/complete codes, configurable length, controlled completion feedback, and disabled/read-only/invalid states. The Complete story starts filled; `onComplete` fires for user changes, not initial values. Codes are demo-only and are not submitted to a service.

Stories install beside Input OTP under your configured UI alias and use relative imports. Use your existing matching `@storybook/react` packages (verified with 10.6.1), discover `**/*.stories.tsx`, and load your global Tailwind/theme stylesheet. No addons, extra packages, core overwrites, or Storybook config edits are required. Length, type, and default-value Controls remount uncontrolled examples; ControlledCompletion owns local state. Storybook UI is not verified because no server exists here.

## Behavior & Accessibility [#behavior--accessibility]

The visual cells are decorative; one native input supports keyboard editing, paste, and one-time-code autofill. `onComplete` runs only when a user edit changes the value to a complete code. Give the control an accessible name.

## Props [#props]

| Prop          | Type                        | Default         | Description                                            |
| ------------- | --------------------------- | --------------- | ------------------------------------------------------ |
| aria-label    | string                      | "One-time code" | InputOTP: accessible name for the hidden native input. |
| defaultValue  | string                      | ""              | InputOTP: initial uncontrolled value.                  |
| disabled      | boolean                     | false           | InputOTP: native disabled input behavior.              |
| length        | number                      | 6               | InputOTP: positive number of code characters.          |
| onComplete    | (value: string) => void     | undefined       | InputOTP: fires for changed complete user input.       |
| onValueChange | (value: string) => void     | undefined       | InputOTP: fires when sanitized value changes.          |
| type          | "numeric" \| "alphanumeric" | "numeric"       | InputOTP: accepted code alphabet.                      |
| value         | string                      | uncontrolled    | InputOTP: controlled sanitized value.                  |

The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.

## Source [#source]

```tsx
"use client";

import { cn } from "cn";
import * as React from "react";

import { fieldSurfaceClassName } from "./input";

export type InputOTPProps = Omit<
  React.ComponentProps<"input">,
  "value" | "defaultValue" | "maxLength"
> & {
  length?: number;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  onComplete?: (value: string) => void;
  type?: "numeric" | "alphanumeric";
};

export const InputOTP = React.forwardRef<HTMLInputElement, InputOTPProps>(
  function InputOTP(
    {
      length = 6,
      value,
      defaultValue = "",
      onValueChange,
      onComplete,
      type = "numeric",
      className,
      onChange,
      ...props
    },
    forwardedRef
  ) {
    const sanitize = React.useCallback(
      (raw: string) =>
        raw
          .replace(type === "numeric" ? /[^0-9]/g : /[^a-z0-9]/gi, "")
          .slice(0, length),
      [length, type]
    );
    const [internal, setInternal] = React.useState(() =>
      sanitize(defaultValue)
    );
    const [focused, setFocused] = React.useState(false);
    const current = sanitize(value === undefined ? internal : value);
    const previous = React.useRef(current);
    const inputRef = React.useRef<HTMLInputElement>(null);
    React.useImperativeHandle(
      forwardedRef,
      () => inputRef.current as HTMLInputElement
    );
    if (!Number.isInteger(length) || length < 1) {
      throw new Error("InputOTP length must be a positive integer.");
    }
    React.useEffect(() => {
      if (value !== undefined) {
        previous.current = current;
      }
    }, [current, value]);

    const update = (
      raw: string,
      event: React.ChangeEvent<HTMLInputElement>
    ) => {
      const next = sanitize(raw);
      if (value === undefined) {
        setInternal(next);
      }
      if (next !== previous.current) {
        previous.current = next;
        onValueChange?.(next);
        if (next.length === length) {
          onComplete?.(next);
        }
      }
      onChange?.(event);
    };

    return (
      <div
        data-slot="input-otp"
        data-focused={focused}
        className={cn("group relative inline-flex min-w-0 gap-2", className)}
      >
        <div aria-hidden="true" className="flex gap-2">
          {Array.from({ length }, (_, index) => (
            <span
              key={index}
              data-filled={index < current.length}
              className={cn(
                "flex size-11 items-center justify-center rounded-md border border-input text-lg text-foreground data-[filled=true]:border-ring group-data-[focused=true]:ring-[3px] group-data-[focused=true]:ring-ring/50",
                fieldSurfaceClassName
              )}
            >
              {current[index] ?? ""}
            </span>
          ))}
        </div>
        <input
          {...props}
          ref={inputRef}
          aria-label={props["aria-label"] ?? "One-time code"}
          autoComplete={props.autoComplete ?? "one-time-code"}
          inputMode={type === "numeric" ? "numeric" : "text"}
          maxLength={length}
          value={current}
          onChange={(event) => update(event.currentTarget.value, event)}
          onFocus={(event) => {
            setFocused(true);
            props.onFocus?.(event);
          }}
          onBlur={(event) => {
            setFocused(false);
            props.onBlur?.(event);
          }}
          className="absolute inset-0 size-full cursor-text opacity-0"
        />
      </div>
    );
  }
);

```
