Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/otp-input.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
otp-input.tsx. It's yours: change anything.
Usage
"use client"
import { OtpInput } from "@/components/otp-input"
export function Verify() {
return (
<OtpInput
label="Verification code"
separators={[3]}
onComplete={(code) => console.log("verify", code)}
/>
)
}Source
One file, 192 lines, nothing hidden. Read it before you install it.
"use client";
import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface OtpInputProps
extends Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "maxLength" | "pattern" | "type" | "size"> {
/** Number of characters. */
length?: number;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Called once every slot is filled. */
onComplete?: (value: string) => void;
/** `numeric` shows the number pad on phones; `alphanumeric` accepts A–Z too (upper-cased). */
mode?: "numeric" | "alphanumeric";
/** Draw a dash after these slot positions, e.g. `[3]` for 123–456. */
separators?: number[];
/** Accessible name, e.g. "Verification code". */
label: string;
/** Marks the code wrong: red boxes and a shake. */
invalid?: boolean;
/** Marks the code accepted: the boxes light up in a wave. */
success?: boolean;
/** Show dots instead of the characters, e.g. for a PIN. */
mask?: boolean;
}
// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
/**
* A one-time-code field drawn as separate boxes.
*
* It is a single real `<input>` under the boxes, so everything that works in
* a text field works here: paste, SMS autofill (`autocomplete="one-time-code"`),
* password managers, undo, screen readers reading the whole code, and
* selecting it all. Invalid characters are dropped as you type or paste.
*
* The focus ring glides from box to box, characters settle in as they're
* typed, `invalid` shakes the row and `success` lights it up in a wave —
* all without movement under `prefers-reduced-motion`.
*/
export function OtpInput({
length = 6,
value: valueProp,
defaultValue = "",
onValueChange,
onComplete,
mode = "numeric",
separators = [],
label,
invalid = false,
success = false,
mask = false,
disabled,
className,
onFocus,
onBlur,
onSelect,
...props
}: OtpInputProps) {
const ringId = React.useId();
const reduceMotion = useReducedMotion();
const [scope, animate] = useAnimate<HTMLDivElement>();
const inputRef = React.useRef<HTMLInputElement>(null);
const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
const value = (valueProp ?? uncontrolled).slice(0, length);
const [focused, setFocused] = React.useState(false);
const [caret, setCaret] = React.useState(value.length);
const clean = (raw: string) =>
(mode === "numeric" ? raw.replace(/\D/g, "") : raw.toUpperCase().replace(/[^A-Z0-9]/g, "")).slice(0, length);
const syncCaret = () => {
const input = inputRef.current;
if (input) setCaret(Math.min(input.selectionStart ?? input.value.length, length - 1));
};
const active = focused ? Math.min(caret, value.length, length - 1) : -1;
// Shake when the code turns out wrong.
React.useEffect(() => {
if (!invalid || reduceMotion || !scope.current) return;
void animate(scope.current, { x: [0, -8, 7, -5, 3, 0] }, { duration: 0.4, ease: [0.22, 1, 0.36, 1] });
}, [invalid, reduceMotion, animate, scope]);
return (
<div
ref={scope}
className={cn("relative inline-flex items-center gap-2", disabled && "opacity-50", className)}
onPointerDown={(e) => {
// Clicking any box focuses the real field at the end of what's typed.
if (e.target === inputRef.current) return;
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.setSelectionRange(value.length, value.length);
syncCaret();
}}
>
<input
ref={inputRef}
// Masked codes stay hidden from screen readers and screenshots too.
type={mask ? "password" : "text"}
aria-label={label}
aria-invalid={invalid || undefined}
autoComplete="one-time-code"
inputMode={mode === "numeric" ? "numeric" : "text"}
autoCapitalize="characters"
spellCheck={false}
// No maxLength: the browser would cut "123 456" to "123 45" before we strip the space.
disabled={disabled}
value={value}
onChange={(e) => {
const next = clean(e.target.value);
if (valueProp === undefined) setUncontrolled(next);
onValueChange?.(next);
if (next.length === length && value.length !== length) onComplete?.(next);
requestAnimationFrame(syncCaret);
}}
onFocus={(e) => {
onFocus?.(e);
setFocused(true);
syncCaret();
}}
onBlur={(e) => {
onBlur?.(e);
setFocused(false);
}}
onSelect={(e) => {
onSelect?.(e);
syncCaret();
}}
// Covers the boxes so taps and long-press paste land on the real field; invisible itself.
className="absolute inset-0 z-10 w-full cursor-text bg-transparent text-transparent caret-transparent opacity-0 outline-none selection:bg-transparent disabled:cursor-not-allowed"
{...props}
/>
{Array.from({ length }, (_, i) => (
<React.Fragment key={i}>
<motion.div
aria-hidden="true"
data-active={i === active || undefined}
data-filled={i < value.length || undefined}
animate={success && !reduceMotion ? { scale: [1, 1.08, 1] } : { scale: 1 }}
// Keyframes need a tween; springs only take two values.
transition={success ? { duration: 0.4, ease: [0.22, 1, 0.36, 1], delay: i * 0.04 } : SPRING_SNAPPY}
className={cn(
"relative flex size-12 items-center justify-center rounded-lg border bg-card font-mono text-lg font-medium tabular-nums text-foreground",
"transition-[border-color,color,background-color] duration-200 motion-reduce:transition-none",
invalid ? "border-destructive/70 text-destructive" : success ? "border-[var(--otp-success,var(--primary))] text-[var(--otp-success,var(--primary))]" : "border-input",
)}
>
{i === active && (
<motion.span
layoutId={`${ringId}-ring`}
transition={reduceMotion ? { duration: 0 } : SPRING_SNAPPY}
className={cn(
"absolute -inset-px rounded-lg border",
invalid
? "border-destructive shadow-[0_0_0_3px_color-mix(in_oklab,var(--destructive)_25%,transparent)]"
: "border-ring shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_25%,transparent)]",
)}
/>
)}
{/* Characters there on first render (defaultValue) don't animate, so server and client agree. */}
<AnimatePresence initial={false}>
{value[i] !== undefined && (
<motion.span
key={`${i}-${value[i]}`}
initial={{ opacity: 0, y: 6, scale: 0.8, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={reduceMotion ? { duration: 0 } : SPRING_SNAPPY}
>
{mask ? "•" : value[i]}
</motion.span>
)}
</AnimatePresence>
{i === active && i >= value.length && (
<span className="absolute h-5 w-px animate-[ux-caret-blink_1.1s_steps(1)_infinite] bg-foreground motion-reduce:animate-none" />
)}
</motion.div>
{separators.includes(i + 1) && i < length - 1 && (
<span aria-hidden="true" className="h-px w-3 rounded-full bg-muted-foreground/60" />
)}
</React.Fragment>
))}
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Accessible name, e.g. "Verification code". |
| length | number | 6 | Number of characters. |
| value / defaultValue | string | — / "" | Controlled or uncontrolled value. |
| onValueChange | (value: string) => void | — | Called on every change. |
| onComplete | (value: string) => void | — | Called once all slots are filled. |
| mode | "numeric" | "alphanumeric" | "numeric" | Numeric shows the number pad on phones. |
| separators | number[] | [] | Draw a dash after these positions. |
| invalid | boolean | false | Marks the code wrong: red boxes and a shake. |
| success | boolean | false | Marks the code accepted: the boxes light up in a wave. |
| mask | boolean | false | Dots instead of characters, and a password field underneath — for PINs. |
Accessibility
- One real <input> with autocomplete="one-time-code": SMS autofill, paste and password managers work.
- Screen readers read the whole code as one field; the boxes are aria-hidden.
- Invalid characters are dropped while typing or pasting.
- mask makes the field a password input, so the code isn't read aloud or shown.
- Under prefers-reduced-motion there is no shake, wave or glide, and the caret doesn't blink.
Changelog
- v1.0.0Latest
First release.