- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
"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:
- cn
$ pnpm add cnUsage
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.
"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
| Prop | Type / Description | Default |
|---|---|---|
| aria-label | stringInputOTP: accessible name for the hidden native input. | "One-time code" |
| defaultValue | stringInputOTP: initial uncontrolled value. | "" |
| disabled | booleanInputOTP: native disabled input behavior. | false |
| length | numberInputOTP: positive number of code characters. | 6 |
| onComplete | (value: string) => voidInputOTP: fires for changed complete user input. | undefined |
| onValueChange | (value: string) => voidInputOTP: fires when sanitized value changes. | undefined |
| type | "numeric" | "alphanumeric"InputOTP: accepted code alphabet. | "numeric" |
| value | stringInputOTP: 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>
);
}
);