All componentsform
OTP Input
Click anywhere in the boxes to focus, type digits, or paste a full code.
Current value: (empty). Not yet complete.
Installation
$ npx shadcn@latest add https://deepmoon.dev/r/otp-input.jsonProps
OtpInputProps
| Prop | Type | Required |
|---|---|---|
| length | number | No |
| value | string | Yes |
| onChange | (value: string) => void | Yes |
| onComplete | (value: string) => void | No |
| disabled | boolean | No |
| className | string | No |
| aria-label | string | No |
Code
otp-input.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface OtpInputProps {
length?: number;
value: string;
onChange: (value: string) => void;
onComplete?: (value: string) => void;
disabled?: boolean;
className?: string;
"aria-label"?: string;
}
/**
* One real, full-size <input> sits transparently over the visual boxes —
* not N separate single-character inputs. That single-input model is what
* makes paste-to-fill, native caret movement (Arrow keys), and screen
* reader behavior all work for free, instead of needing bespoke paste
* splitting and auto-advance-on-type logic across many inputs.
*/
export function OtpInput({
length = 6,
value,
onChange,
onComplete,
disabled = false,
className,
"aria-label": ariaLabel = "One-time code",
}: OtpInputProps) {
const [focused, setFocused] = React.useState(false);
const activeIndex = Math.min(value.length, length - 1);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const next = event.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, length);
onChange(next);
if (next.length === length) onComplete?.(next);
};
return (
<div className={cn("relative inline-flex gap-2", className)}>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
aria-label={ariaLabel}
value={value}
disabled={disabled}
onChange={handleChange}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
maxLength={length}
className="absolute inset-0 z-10 h-full w-full cursor-text text-transparent caret-transparent opacity-0 outline-none disabled:cursor-not-allowed"
/>
{Array.from({ length }, (_, index) => (
<div
key={index}
aria-hidden="true"
className={cn(
"flex size-10 items-center justify-center rounded-md border border-input bg-background text-base font-medium text-foreground",
focused && index === activeIndex && "border-ring ring-2 ring-ring",
disabled && "opacity-50",
)}
>
{value[index] ??
(focused && index === activeIndex ? (
<span className="h-4 w-px bg-foreground motion-safe:animate-pulse" />
) : null)}
</div>
))}
</div>
);
}