Inputs & Forms

OTP Input

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.

Install

npx shadcn@latest add @paragon/otp-input

otp-input.tsx

"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>
  );
}