A 6-cell one-time-code input driven by a single invisible field, so paste, autofill, and auto-advance work natively; digits pop in and errors shake the row.
npx shadcn@latest add @paragon/otp-input"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface OtpInputProps
extends Omit<
React.ComponentProps<"input">,
"value" | "defaultValue" | "onChange" | "size" | "type" | "maxLength"
> {
/** Number of code cells. */
length?: number;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Fired once when every cell is filled. */
onComplete?: (value: string) => void;
/** Error state: destructive borders plus a one-shot shake across the row. */
error?: boolean;
/** Visible label, wired via htmlFor. */
label?: string;
}
/**
* One-time-code input. A single invisible input drives the visual cells, so
* paste, autofill (`autocomplete="one-time-code"`), and backspace all work
* natively; typing auto-advances by construction. Filled digits pop from
* 0.97, the active cell shows the focus ring, and errors shake the row.
*/
export function OtpInput({
length = 6,
value,
defaultValue = "",
onValueChange,
onComplete,
error = false,
label,
className,
id: idProp,
disabled,
"aria-label": ariaLabel,
...props
}: OtpInputProps) {
const autoId = React.useId();
const id = idProp ?? autoId;
const inputRef = React.useRef<HTMLInputElement>(null);
const sanitize = React.useCallback(
(raw: string) => raw.replace(/\D/g, "").slice(0, length),
[length],
);
const [internal, setInternal] = React.useState(() => sanitize(defaultValue));
const code = value !== undefined ? sanitize(value) : internal;
const [focused, setFocused] = React.useState(false);
const [shaking, setShaking] = React.useState(false);
React.useEffect(() => {
if (error) setShaking(true);
}, [error]);
const activeIndex = Math.min(code.length, length - 1);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const next = sanitize(event.target.value);
if (value === undefined) setInternal(next);
onValueChange?.(next);
if (next.length === length && next !== code) onComplete?.(next);
};
return (
<div className={cn("w-fit", className)}>
<style href="paragon-otp-input" precedence="paragon">{`
@keyframes paragon-otp-pop {
from { opacity: 0; scale: 0.97; }
}
@keyframes paragon-otp-shake {
0% { translate: 0; animation-timing-function: cubic-bezier(0.36, 0, 0.66, 0.2); }
25% { translate: -6px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
50% { translate: 5px 0; animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
75% { translate: -3px 0; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }
100% { translate: 0; }
}
@keyframes paragon-otp-caret {
0%, 45% { opacity: 1; }
55%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.paragon-otp-row, .paragon-otp-digit { animation: none !important; }
.paragon-otp-caret { animation: none !important; opacity: 1 !important; }
}
`}</style>
{label && (
<label
htmlFor={id}
className="mb-1.5 block text-sm font-medium text-foreground"
>
{label}
</label>
)}
<div
className={cn("paragon-otp-row relative flex gap-2", disabled && "opacity-50")}
style={
shaking ? { animation: "paragon-otp-shake 280ms both" } : undefined
}
onAnimationEnd={(event) => {
if (event.animationName === "paragon-otp-shake") setShaking(false);
}}
>
{Array.from({ length }, (_, index) => {
const char = code[index];
const active = focused && index === activeIndex;
return (
<div
key={index}
aria-hidden
className={cn(
"flex h-11 w-9 items-center justify-center rounded-lg border bg-transparent text-base font-medium tabular-nums",
"transition-[border-color,box-shadow] duration-150 ease-out",
error ? "border-destructive" : "border-input",
active &&
(error
? "border-destructive ring-[3px] ring-destructive/20"
: "border-ring ring-[3px] ring-ring/25"),
)}
>
{char ? (
<span
className="paragon-otp-digit"
style={{
animation: "paragon-otp-pop 200ms var(--ease-out) both",
}}
>
{char}
</span>
) : active ? (
<span
className="paragon-otp-caret h-4 w-px bg-foreground"
style={{
animation: "paragon-otp-caret 1.1s step-end infinite",
}}
/>
) : null}
</div>
);
})}
<input
{...props}
ref={inputRef}
id={id}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d*"
aria-label={label ? undefined : (ariaLabel ?? "One-time code")}
aria-invalid={error || undefined}
disabled={disabled}
value={code}
onChange={handleChange}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
// Keep the caret parked at the end so typing always targets the
// active cell and backspace deletes the last digit.
onSelect={(event) => {
const el = event.currentTarget;
el.setSelectionRange(el.value.length, el.value.length);
}}
className="absolute inset-0 h-full w-full cursor-default opacity-0 outline-none disabled:cursor-not-allowed"
/>
</div>
</div>
);
}