Input OTP
A one-time-code field rendered as a row of milled cells over a single real input — paste, autofill, and screen readers all behave normally.
@emboss/input-otp
Installation
pnpm dlx shadcn@latest add @emboss/input-otpUsage
import { InputOtp } from "@/components/ui/input-otp";
export function Example() {
return <InputOtp length={6} aria-label="Verification code" />;
}API reference
InputOtp
Extends the native input element. Editing happens at the end of the value, like a terminal prompt.
| Prop | Type | Default | Description |
|---|---|---|---|
| length | number | 6 | Number of character cells. |
| value / defaultValue / onValueChange | string / string / (value) => void | — | Controlled and uncontrolled usage. |
| Data attribute | Description |
|---|---|
| data-active | Present on the cell that will receive the next character while focused. |
Source
View source — input-otp.tsxHide source — input-otp.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputOtpProps extends Omit<
React.ComponentProps<"input">,
"value" | "defaultValue" | "onChange" | "size" | "maxLength"
> {
/** Number of character cells. */
length?: number;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
}
/**
* A one-time-code field rendered as a row of milled cells. A single real
* input sits invisibly over the row, so paste, autofill, and screen readers
* all behave like a normal text field.
*
* @example
* <InputOtp length={6} aria-label="Verification code" />
*/
function InputOtp({
length = 6,
value,
defaultValue,
onValueChange,
disabled,
className,
onFocus,
onBlur,
...props
}: InputOtpProps) {
const [internal, setInternal] = React.useState(defaultValue ?? "");
const [focused, setFocused] = React.useState(false);
const controlled = value !== undefined;
const current = controlled ? value : internal;
const activeIndex = Math.min(current.length, length - 1);
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
const next = event.target.value.replace(/\s+/g, "").slice(0, length);
if (!controlled) setInternal(next);
onValueChange?.(next);
};
return (
<div
data-slot="input-otp"
className={cn(
"relative inline-flex gap-2",
disabled && "pointer-events-none opacity-50",
className,
)}
>
<input
data-slot="input-otp-input"
autoComplete="one-time-code"
inputMode="numeric"
{...props}
value={current}
maxLength={length}
disabled={disabled}
onChange={handleChange}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
className="absolute inset-0 z-10 cursor-text opacity-0"
/>
{Array.from({ length }, (_, index) => {
const active = focused && index === activeIndex;
return (
<div
key={index}
aria-hidden
data-slot="input-otp-cell"
data-active={active ? "" : undefined}
className="flex h-10 w-8 items-center justify-center rounded-sm bg-well font-mono text-base text-ink shadow-deboss-2 transition-[background-color,box-shadow] duration-(--duration-press) ease-(--ease-press) data-active:bg-surface-1 data-active:shadow-deboss-1"
>
{current[index] ??
(active ? (
<span className="h-4 w-px animate-depth-pulse bg-ink" />
) : null)}
</div>
);
})}
</div>
);
}
export { InputOtp };