Overview

A production-ready one-time passcode field: per-digit slots, shared-layout focus ring, full-code paste / autofill, arrow + backspace navigation, and an invalid state that shakes then briefly scrambles digits.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/otp-field.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/otp-field.tsx
tsx
"use client";
import React, {  useCallback,  useEffect,  useId,  useRef,  useState,} from "react";import { motion, useReducedMotion, animate } from "motion/react";import { cn } from "@/lib/utils";
export type OtpFieldProps = {  length?: number;  value?: string;  defaultValue?: string;  onChange?: (value: string) => void;  onComplete?: (value: string) => void;  /** Triggers error shake + optional scramble */  invalid?: boolean;  onInvalidChange?: (invalid: boolean) => void;  disabled?: boolean;  autoFocus?: boolean;  className?: string;  "aria-label"?: string;};
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.32 };
function onlyDigits(s: string) {  return s.replace(/\D/g, "");}
export function OtpField({  length = 6,  value: valueProp,  defaultValue = "",  onChange,  onComplete,  invalid = false,  onInvalidChange,  disabled,  autoFocus,  className,  "aria-label": ariaLabel = "One-time passcode",}: OtpFieldProps) {  const reduce = useReducedMotion();  const baseId = useId();  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(    onlyDigits(defaultValue).slice(0, length),  );  const value = (isControlled ? valueProp : uncontrolled).slice(0, length);  const inputRefs = useRef<(HTMLInputElement | null)[]>([]);  const [shake, setShake] = useState(0);  const [focusIndex, setFocusIndex] = useState(-1);  const [displayOverride, setDisplayOverride] = useState<string | null>(null);
  const shown = displayOverride ?? value;  const digits = Array.from({ length }, (_, i) => shown[i] ?? "");
  const setValue = useCallback(    (next: string) => {      const cleaned = onlyDigits(next).slice(0, length);      if (!isControlled) setUncontrolled(cleaned);      onChange?.(cleaned);      if (cleaned.length === length) onComplete?.(cleaned);    },    [isControlled, length, onChange, onComplete],  );
  useEffect(() => {    if (!invalid) return;    if (reduce) {      onInvalidChange?.(false);      return;    }    setShake((n) => n + 1);    const el = document.getElementById(`${baseId}-track`);    if (el) {      animate(        el,        { x: [0, -8, 8, -6, 6, -3, 0] },        { duration: 0.28, ease: [0.16, 1, 0.3, 1] },      );    }    if (value.length === length) {      const scramble = Array.from({ length }, () =>        String(Math.floor(Math.random() * 10)),      ).join("");      setDisplayOverride(scramble);      const t = window.setTimeout(() => {        setDisplayOverride(null);        onInvalidChange?.(false);      }, 280);      return () => window.clearTimeout(t);    }    onInvalidChange?.(false);  }, [invalid, reduce, baseId, value, length, onInvalidChange]);
  useEffect(() => {    if (!autoFocus) return;    // Don't steal scroll on mount — only focus if already in view    const el = inputRefs.current[0];    if (!el) return;    const rect = el.getBoundingClientRect();    const inView =      rect.top >= 0 &&      rect.bottom <= (window.innerHeight || document.documentElement.clientHeight);    if (inView) {      el.focus({ preventScroll: true });      setFocusIndex(0);    }  }, [autoFocus]);
  const focusAt = (index: number) => {    const clamped = Math.min(Math.max(index, 0), length - 1);    setFocusIndex(clamped);    inputRefs.current[clamped]?.focus();    inputRefs.current[clamped]?.select();  };
  const writeAt = (index: number, char: string) => {    const chars = Array.from({ length }, (_, i) => value[i] ?? "");    chars[index] = char.slice(0, 1);    const cleaned = onlyDigits(chars.join(""));    setValue(cleaned);    if (char && index < length - 1) focusAt(index + 1);  };
  const onPaste = (e: React.ClipboardEvent) => {    e.preventDefault();    const pasted = onlyDigits(e.clipboardData.getData("text")).slice(0, length);    if (!pasted) return;    setValue(pasted);    focusAt(Math.min(pasted.length, length - 1));  };
  return (    <div      id={`${baseId}-track`}      role="group"      aria-label={ariaLabel}      className={cn("inline-flex gap-2", className)}      data-shake={shake}    >      {digits.map((digit, index) => {        const filled = digit !== "";        const focused = focusIndex === index;        return (          <div key={index} className="relative">            {focused && !disabled && (              <motion.span                layoutId={`${baseId}-focus`}                className={cn(                  "pointer-events-none absolute inset-0 rounded-md",                  "ring-2 ring-(--color-focus) ring-offset-2 ring-offset-(--color-paper)",                )}                transition={reduce ? { duration: 0.12 } : SPRING}              />            )}            <input              ref={(el) => {                inputRefs.current[index] = el;              }}              id={`${baseId}-${index}`}              type="text"              inputMode="numeric"              autoComplete={index === 0 ? "one-time-code" : "off"}              pattern="[0-9]*"              maxLength={1}              disabled={disabled}              value={digit}              aria-label={`Digit ${index + 1} of ${length}`}              onFocus={() => setFocusIndex(index)}              onBlur={(e) => {                // Clear ring when focus leaves the whole OTP group                const next = e.relatedTarget as Node | null;                if (!e.currentTarget.parentElement?.parentElement?.contains(next)) {                  setFocusIndex(-1);                }              }}              onPaste={onPaste}              onKeyDown={(e) => {                if (e.key === "Backspace") {                  e.preventDefault();                  if (digits[index]) {                    writeAt(index, "");                  } else if (index > 0) {                    writeAt(index - 1, "");                    focusAt(index - 1);                  }                  return;                }                if (e.key === "ArrowLeft") {                  e.preventDefault();                  focusAt(index - 1);                }                if (e.key === "ArrowRight") {                  e.preventDefault();                  focusAt(index + 1);                }              }}              onChange={(e) => {                const raw = onlyDigits(e.target.value);                if (!raw) {                  writeAt(index, "");                  return;                }                if (raw.length > 1) {                  // Mobile autofill may dump full code into one field                  setValue(raw.slice(0, length));                  focusAt(Math.min(raw.length, length - 1));                  return;                }                writeAt(index, raw);              }}              className={cn(                "relative z-1 size-11 text-center text-lg font-medium tabular-nums",                "rounded-md border border-(--color-rule)",                "bg-(--color-paper) text-(--color-ink)",                "outline-none",                "transition-[border-color,background-color,transform] duration-(--dur-micro) ease-out",                "focus:border-(--color-ink-2)",                filled && "bg-(--color-paper-2)",                invalid && "border-red-600/50",                "disabled:opacity-50",                "active:scale-[0.96]",              )}            />          </div>        );      })}    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { OtpField } from "@/components/ui/otp-field";
<OtpField  length={6}  onComplete={(code) => verify(code)}  invalid={isWrong}  onInvalidChange={setIsWrong}/>

Props

PropTypeRequiredDefaultDescription
lengthnumberNo6Number of digit slots.
valuestringNoControlled digit string.
onChange(value: string) => voidNoFires on every digit change.
onComplete(value: string) => voidNoFires when all slots are filled.
invalidbooleanNoTriggers shake + scramble feedback.
autoFocusbooleanNoFocus the first slot on mount.

Best Practices

  1. Use tabular-nums so digits don't shift slot width.
  2. Wire autoComplete="one-time-code" on the first slot (already set).
  3. Invalid scramble is delight-tier — skip it under reduced motion.
  4. Clear invalid via onInvalidChange after the animation so it can re-fire.