Overview

A one-time passcode field built on a single real input laid over drawn slots, so SMS autofill, paste, password managers and screen readers all see one ordinary text field. Typing into a filled slot replaces just that digit; arrows select one slot at a time; backspace clears and steps back. Pasting strips spaces and dashes. A wrong code shakes, scrambles, shows a message and clears for the next try; a verified code locks in green.


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,  useLayoutEffect,  useRef,  useState,} from "react";import {  AnimatePresence,  motion,  useAnimate,  useReducedMotion,} from "motion/react";import { cn } from "@/lib/utils";
export type OtpFieldProps = {  length?: number;  value?: string;  defaultValue?: string;  onChange?: (value: string) => void;  /** Fires when the last slot is filled (and again if a full code is edited). */  onComplete?: (value: string) => void;  /** Pulse true to trigger the error: shake, scramble, message. */  invalid?: boolean;  onInvalidChange?: (invalid: boolean) => void;  /** Clear the code after an invalid attempt so the next try starts fresh. */  clearOnInvalid?: boolean;  /** Locks the field in a verified state. */  success?: boolean;  errorMessage?: string;  /** Visual gap after every n slots. Defaults to 3 for a 6-digit code. */  groupSize?: number;  disabled?: boolean;  autoFocus?: boolean;  className?: string;  "aria-label"?: string;};
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.32 };const EASE_OUT = [0.23, 1, 0.32, 1] as const;
function onlyDigits(s: string) {  return s.replace(/\D/g, "");}
export function OtpField({  length: lengthProp = 6,  value: valueProp,  defaultValue = "",  onChange,  onComplete,  invalid = false,  onInvalidChange,  clearOnInvalid = true,  success = false,  errorMessage = "Invalid code. Try again.",  groupSize,  disabled,  autoFocus,  className,  "aria-label": ariaLabel = "One-time passcode",}: OtpFieldProps) {  const length = Number.isFinite(lengthProp)    ? Math.max(1, Math.floor(lengthProp))    : 6;  const group = groupSize ?? (length === 6 ? 3 : 0);  const reduce = useReducedMotion();  const baseId = useId();  const errorId = `${baseId}-error`;
  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(    onlyDigits(defaultValue).slice(0, length),  );  const value = onlyDigits(isControlled ? valueProp : uncontrolled).slice(0, length);
  const inputRef = useRef<HTMLInputElement>(null);  const slotRefs = useRef<(HTMLDivElement | null)[]>([]);  const [trackRef, animateTrack] = useAnimate<HTMLDivElement>();
  const [focused, setFocused] = useState(false);  const [selection, setSelection] = useState<[number, number]>([0, 0]);  const [error, setError] = useState(false);  const [scramble, setScramble] = useState<string | null>(null);  // Where the caret should land after the next committed value.  const pendingCaret = useRef<number | null>(null);  const prevValue = useRef(value);
  const callbacks = useRef({ onChange, onComplete, onInvalidChange });  useEffect(() => {    callbacks.current = { onChange, onComplete, onInvalidChange };  }, [onChange, onComplete, onInvalidChange]);
  const setValue = useCallback(    (next: string, caret?: number) => {      const cleaned = onlyDigits(next).slice(0, length);      if (caret !== undefined) pendingCaret.current = Math.min(caret, cleaned.length);      if (!isControlled) setUncontrolled(cleaned);      callbacks.current.onChange?.(cleaned);      if (cleaned.length === length && cleaned !== prevValue.current) {        callbacks.current.onComplete?.(cleaned);      }      prevValue.current = cleaned;    },    [isControlled, length],  );
  useEffect(() => {    prevValue.current = value;  }, [value]);
  /* ── Caret model ──────────────────────────────────────── */  // A filled slot is selected as a single character, so typing overwrites it  // in place. Past the last digit the caret is collapsed so typing appends.  const placeCaret = useCallback(    (index: number) => {      const input = inputRef.current;      if (!input) return;      const len = input.value.length;      const i = Math.max(0, Math.min(index, len, length - 1));      if (i < len) input.setSelectionRange(i, i + 1);      else input.setSelectionRange(len, len);      setSelection([input.selectionStart ?? i, input.selectionEnd ?? i]);    },    [length],  );
  useLayoutEffect(() => {    if (pendingCaret.current === null) return;    const caret = pendingCaret.current;    pendingCaret.current = null;    if (document.activeElement === inputRef.current) placeCaret(caret);  }, [value, placeCaret]);
  const syncSelection = () => {    const input = inputRef.current;    if (!input) return;    setSelection([input.selectionStart ?? 0, input.selectionEnd ?? 0]);  };
  const activeIndex = !focused    ? -1    : Math.min(selection[0], length - 1);
  /* ── Invalid: shake, scramble, clear ─────────────────── */  useEffect(() => {    if (!invalid) return;    // Hand the pulse straight back so the parent can fire it again.    callbacks.current.onInvalidChange?.(false);    setError(true);
    if (reduce) {      if (clearOnInvalid) setValue("", 0);      return;    }
    const full = value.length === length;    const scrambler = full      ? window.setInterval(() => {          setScramble(            Array.from({ length }, () => String(Math.floor(Math.random() * 10))).join(""),          );        }, 45)      : 0;
    const shake = animateTrack(      trackRef.current,      { x: [0, -9, 8, -6, 4, -2, 0] },      { duration: 0.42, ease: "easeOut" },    );    shake.then(() => {      window.clearInterval(scrambler);      setScramble(null);      if (clearOnInvalid) setValue("", 0);    });
    return () => {      window.clearInterval(scrambler);    };    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [invalid]);
  useEffect(() => {    if (!autoFocus) return;    const el = inputRef.current;    if (!el) return;    const rect = el.getBoundingClientRect();    if (rect.top >= 0 && rect.bottom <= window.innerHeight) {      el.focus({ preventScroll: true });    }  }, [autoFocus]);
  /* ── Input handlers ───────────────────────────────────── */
  const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {    const input = e.currentTarget;    const raw = input.value;    const digits = onlyDigits(raw).slice(0, length);    // Map the native caret through any characters we stripped.    const caretRaw = input.selectionStart ?? raw.length;    const caret = onlyDigits(raw.slice(0, caretRaw)).length;    setError(false);    setValue(digits, caret);  };
  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {    const input = e.currentTarget;    const start = input.selectionStart ?? 0;    const end = input.selectionEnd ?? 0;
    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {      e.preventDefault();      const step = e.key === "ArrowLeft" ? -1 : 1;      placeCaret(Math.min(start, length - 1) + step);      return;    }    if (e.key === "Home" || e.key === "End") {      e.preventDefault();      placeCaret(e.key === "Home" ? 0 : length);      return;    }    if (e.key === "Backspace" && !e.metaKey && !e.ctrlKey && !e.altKey) {      e.preventDefault();      setError(false);      if (end - start > 1) {        setValue(value.slice(0, start) + value.slice(end), start);        return;      }      if (start === end) {        // Caret after the last digit: remove it.        if (start === 0) return;        setValue(value.slice(0, start - 1) + value.slice(start), start - 1);        return;      }      // A slot is selected: clear it and step back.      setValue(value.slice(0, start) + value.slice(start + 1), Math.max(0, start - 1));    }  };
  const onPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {    e.preventDefault();    const pasted = onlyDigits(e.clipboardData.getData("text"));    if (!pasted) return;    setError(false);    if (pasted.length >= length) {      setValue(pasted.slice(0, length), length);      return;    }    const input = e.currentTarget;    const start = input.selectionStart ?? value.length;    const end = input.selectionEnd ?? start;    const next = (value.slice(0, start) + pasted + value.slice(end)).slice(0, length);    setValue(next, start + pasted.length);  };
  // Clicks land on the transparent input; route them to the slot under the pointer.  const onPointerUp = (e: React.PointerEvent<HTMLInputElement>) => {    const x = e.clientX;    let index = length;    for (let i = 0; i < length; i++) {      const rect = slotRefs.current[i]?.getBoundingClientRect();      if (rect && x <= rect.right + 4) {        index = i;        break;      }    }    // Pointer-up runs after the browser has placed its own caret, so this wins.    placeCaret(index);  };
  const shown = scramble ?? value;  const locked = disabled || success;
  return (    <div className={cn("inline-flex flex-col items-center gap-2", className)}>      <motion.div ref={trackRef} className="relative">        <div className="flex items-center" aria-hidden>          {Array.from({ length }, (_, index) => {            const digit = shown[index] ?? "";            const filled = digit !== "";            const active = index === activeIndex && !locked;            const showCaret = active && !filled && selection[0] === selection[1];            const gapAfter = group > 0 && (index + 1) % group === 0 && index < length - 1;
            return (              <React.Fragment key={index}>                <motion.div                  ref={(el) => {                    slotRefs.current[index] = el;                  }}                  animate={                    success && !reduce                      ? { y: [0, -4, 0], transition: { delay: index * 0.04, duration: 0.36, ease: EASE_OUT } }                      : undefined                  }                  className={cn(                    "relative grid h-13 w-11 place-items-center rounded-[12px] sm:w-12",                    "border bg-(--color-paper) text-[22px] font-medium tabular-nums",                    "shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_1px_2px_rgba(0,0,0,0.04)]",                    "dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]",                    "transition-[border-color,background-color,color] duration-(--dur-micro) ease-out",                    index > 0 && "ml-2",                    filled ? "border-(--color-ink)/20 text-(--color-ink)" : "border-(--color-rule)",                    error && "border-red-500/60 bg-red-500/[0.04] text-red-600 dark:text-red-400",                    success &&                      "border-emerald-500/60 bg-emerald-500/[0.06] text-emerald-700 dark:text-emerald-400",                    disabled && "opacity-50",                  )}                >                  {active && (                    <motion.span                      layoutId={`${baseId}-focus`}                      className={cn(                        "pointer-events-none absolute -inset-px rounded-[12px]",                        "ring-2 ring-(--color-ink)/80",                        error && "ring-red-500/80",                      )}                      transition={reduce ? { duration: 0 } : SPRING}                    />                  )}
                  <AnimatePresence initial={false} mode="popLayout">                    {filled ? (                      <motion.span                        key={scramble ? `s-${index}` : `${index}-${digit}`}                        initial={reduce || scramble ? false : { opacity: 0, y: 6, scale: 0.8, filter: "blur(2px)" }}                        animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}                        exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: 0.8, transition: { duration: 0.1 } }}                        transition={{ duration: 0.2, ease: EASE_OUT }}                      >                        {digit}                      </motion.span>                    ) : null}                  </AnimatePresence>
                  {showCaret && (                    <motion.span                      animate={reduce ? undefined : { opacity: [1, 1, 0, 0] }}                      transition={{ duration: 1.06, repeat: Infinity, times: [0, 0.5, 0.5, 1] }}                      className="pointer-events-none absolute h-6 w-[1.5px] rounded-full bg-(--color-ink)"                    />                  )}                </motion.div>
                {gapAfter && (                  <span className="ml-2 h-[2px] w-2.5 rounded-full bg-(--color-rule)" />                )}              </React.Fragment>            );          })}        </div>
        {/* The real field: one input over the slots, so autofill, paste,            screen readers and mobile keyboards all see a normal text field. */}        <input          ref={inputRef}          type="text"          inputMode="numeric"          autoComplete="one-time-code"          pattern="[0-9]*"          maxLength={length}          spellCheck={false}          data-1p-ignore          data-lpignore="true"          aria-label={ariaLabel}          aria-invalid={error || undefined}          aria-describedby={error ? errorId : undefined}          disabled={disabled}          readOnly={success}          value={value}          onChange={onInputChange}          onKeyDown={onKeyDown}          onPaste={onPaste}          onSelect={syncSelection}          onPointerUp={onPointerUp}          onFocus={(e) => {            setFocused(true);            // Tab focus selects everything; park on the next empty slot instead.            // Mouse focus is refined again by onPointerUp.            const input = e.currentTarget;            placeCaret(input.value.length);          }}          onBlur={() => setFocused(false)}          className={cn(            // Wider than the track and clipped, so password-manager badges stay hidden.            "absolute inset-y-0 left-0 w-[calc(100%+40px)] [clip-path:inset(0_40px_0_0)]",            "bg-transparent text-[16px] text-transparent caret-transparent outline-none",            // The slots draw the digits. Keep the real text invisible in every            // state: selection (global ::selection sets a text colour), the            // WebKit fill colour, and the autofill paint.            "[-webkit-text-fill-color:transparent] selection:bg-transparent selection:text-transparent",            "autofill:[-webkit-text-fill-color:transparent] autofill:transition-[background-color] autofill:duration-[99999s]",            locked ? "cursor-default" : "cursor-text",          )}        />      </motion.div>
      <div className="min-h-4 text-center text-[12px]">        <AnimatePresence initial={false}>          {error ? (            <motion.div              key="error"              id={errorId}              role="alert"              initial={reduce ? false : { opacity: 0, y: -4 }}              animate={{ opacity: 1, y: 0 }}              exit={{ opacity: 0, transition: { duration: 0.12 } }}              transition={{ duration: 0.2, ease: EASE_OUT }}              className="text-red-600 dark:text-red-400"            >              {errorMessage}            </motion.div>          ) : null}        </AnimatePresence>      </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.
valuestringNo—Controlled digit string.
onChange(value: string) => voidNo—Fires on every digit change.
onComplete(value: string) => voidNo—Fires when all slots are filled.
defaultValuestringNo—Uncontrolled initial digits.
invalidbooleanNo—Pulse true to trigger the error: shake, scramble and message.
onInvalidChange(invalid: boolean) => voidNo—Hands the invalid pulse back (false) so it can fire again.
clearOnInvalidbooleanNotrueClear the code after an invalid attempt and return to the first slot.
errorMessagestringNo"Invalid code. Try again."Message announced and shown under the slots on error.
successbooleanNo—Locks the field in a verified (read-only) state.
groupSizenumberNo3 for 6 digitsVisual divider after every n slots. 0 to disable.
disabledbooleanNo—Disables input.
autoFocusbooleanNo—Focus the first slot on mount.

Best Practices

  1. Use tabular-nums so digits don't shift slot width.
  2. Use one real input with autoComplete="one-time-code" — separate per-digit inputs break SMS autofill on several browsers.
  3. Invalid scramble is delight-tier — skip it under reduced motion, but still show and announce the message.
  4. Clear invalid via onInvalidChange so it can re-fire.
  5. Clear a wrong code for the user; nobody wants to backspace six digits.