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 } 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, "");}
function readMs(name: string, fallback: number) {  const v = parseFloat(    getComputedStyle(document.documentElement).getPropertyValue(name),  );  return Number.isFinite(v) ? v : fallback;}
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 wrapRef = useRef<HTMLDivElement>(null);  const trackRef = useRef<HTMLDivElement>(null);  const revertTimer = useRef<number | null>(null);  const [focusIndex, setFocusIndex] = useState(-1);  const [displayOverride, setDisplayOverride] = useState<string | null>(null);  const [error, setError] = useState(false);  const [shaking, setShaking] = useState(false);
  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],  );
  const clearError = useCallback(() => {    if (revertTimer.current) {      window.clearTimeout(revertTimer.current);      revertTimer.current = null;    }    setError(false);    setShaking(false);    wrapRef.current?.classList.remove("is-error");    trackRef.current?.classList.remove("is-error", "is-shaking");  }, []);
  useEffect(() => {    if (!invalid) return;    if (reduce) {      onInvalidChange?.(false);      return;    }
    const wrap = wrapRef.current;    const track = trackRef.current;    if (!wrap || !track) {      onInvalidChange?.(false);      return;    }
    wrap.classList.add("is-error");    track.classList.add("is-error");    setError(true);
    track.classList.remove("is-shaking");    void track.offsetWidth;    track.classList.add("is-shaking");    setShaking(true);
    const shakeMs =      readMs("--shake-dur-a", 80) * 2 + readMs("--shake-dur-b", 60) * 2;    const shakeClear = window.setTimeout(() => {      track.classList.remove("is-shaking");      setShaking(false);    }, shakeMs + 20);
    if (value.length === length) {      const scramble = Array.from({ length }, () =>        String(Math.floor(Math.random() * 10)),      ).join("");      setDisplayOverride(scramble);      window.setTimeout(() => setDisplayOverride(null), shakeMs);    }
    if (revertTimer.current) window.clearTimeout(revertTimer.current);    const hold = readMs("--revert-hold", 3000);    revertTimer.current = window.setTimeout(() => {      revertTimer.current = null;      wrap.classList.remove("is-error");      track.classList.remove("is-error");      setError(false);      onInvalidChange?.(false);    }, shakeMs + hold);
    // Parent often toggles `invalid` as a pulse — clear the prop flag so it can retrigger    onInvalidChange?.(false);
    return () => {      window.clearTimeout(shakeClear);    };  }, [invalid, reduce, value, length, onInvalidChange]);
  useEffect(() => {    if (!autoFocus) return;    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      ref={wrapRef}      className={cn("t-input-wrap inline-flex flex-col gap-1", className)}    >      <div        ref={trackRef}        id={`${baseId}-track`}        role="group"        aria-label={ariaLabel}        className={cn(          "t-input inline-flex gap-2 rounded-md",          error && "is-error",          shaking && "is-shaking",        )}      >        {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}`}                aria-invalid={error || undefined}                onFocus={() => setFocusIndex(index)}                onBlur={(e) => {                  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) => {                  clearError();                  const raw = onlyDigits(e.target.value);                  if (!raw) {                    writeAt(index, "");                    return;                  }                  if (raw.length > 1) {                    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)",                  error && "border-red-600/50",                  "disabled:opacity-50",                  "active:scale-[0.96]",                )}              />            </div>          );        })}      </div>      <p        className={cn(          "t-error-msg m-0 text-xs text-red-600 dark:text-red-400",        )}        role="alert"      >        Invalid code. Try again.      </p>    </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.