For the complete documentation index, see 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.
1
Support

Input OTP

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

Playground

Live preview
0/6 characters
Paste is supported. Completion feedback appears when the configured number of characters is entered.
Customize input-otp
Customize
input-otp-demo.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

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

$ pnpm dlx 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

Installed automatically by the registry CLI. For manual setup:

Dependencies1 required packages
  • cn
$ pnpm add cn

Usage

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

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

0/6 characters
input-otp-demo.tsx
"use client";

import { useState } from "react";

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

export const InputOTPDemo = () => {
  const [code, setCode] = useState("");
  return (
    <div className="grid justify-items-center gap-2">
      <InputOTP
        aria-label="Verification code"
        length={6}
        value={code}
        onValueChange={setCode}
        onComplete={(value) => console.info("Code ready", value.length)}
      />
      <span className="text-sm text-muted-foreground">
        {code.length}/6 characters
      </span>
    </div>
  );
};

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

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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
aria-labelstring

InputOTP: accessible name for the hidden native input.

"One-time code"
defaultValuestring

InputOTP: initial uncontrolled value.

""
disabledboolean

InputOTP: native disabled input behavior.

false
lengthnumber

InputOTP: positive number of code characters.

6
onComplete(value: string) => void

InputOTP: fires for changed complete user input.

undefined
onValueChange(value: string) => void

InputOTP: fires when sanitized value changes.

undefined
type"numeric" | "alphanumeric"

InputOTP: accepted code alphabet.

"numeric"
valuestring

InputOTP: controlled sanitized value.

uncontrolled

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

"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>
    );
  }
);