# OTP Input

One-time-code input with a blinking caret, digits that pop in, paste support and autofill.

**Category:** Inputs  
**Exports:** `OTPInput`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/otp-input.json
```
The file lands at `components/ui/otp-input.tsx`; import from `@/components/ui/otp-input`.

## API — OTP Input
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `length` | `number` | `6` |  |
| `value` | `string` | — | Controlled value. |
| `defaultValue` | `string` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: string) => void` | — |  |
| `onComplete` | `(code: string) => void` | — |  |
| `error` | `boolean` | — | Shakes and tints the slots. |
| `disabled` | `boolean` | — |  |

## Example — OTP Input

```tsx
import * as React from "react";
import { OTPInput } from "@/components/ui/otp-input";

export default function OTPInputDemo({ length = 6, disabled = false }: { length?: number; disabled?: boolean }) {
  const [code, setCode] = React.useState("");
  const [status, setStatus] = React.useState<"idle" | "error" | "ok">("idle");
  return (
    <div className="flex flex-col items-center gap-4">
      <OTPInput
        length={length}
        value={code}
        onValueChange={(v) => {
          setCode(v);
          setStatus("idle");
        }}
        onComplete={(v) => setStatus(v === "123456" ? "ok" : "error")}
        error={status === "error"}
        disabled={disabled}
      />
      <p className="h-5 text-sm text-muted-foreground" aria-live="polite">
        {status === "error" ? "That code didn't match. Try 123456." : status === "ok" ? "Verified." : "Type any 6 digits, or paste them."}
      </p>
    </div>
  );
}
```

## Source — components/ui/otp-input.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type OTPInputProps = {
  length?: number;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  onComplete?: (code: string) => void;
  /** Shakes the slots and tints them red. */
  error?: boolean;
  disabled?: boolean;
  className?: string;
  "aria-label"?: string;
};

export function OTPInput({
  length = 6,
  value: valueProp,
  defaultValue = "",
  onValueChange,
  onComplete,
  error,
  disabled,
  className,
  "aria-label": ariaLabel = "One-time code",
}: OTPInputProps) {
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState(defaultValue);
  const value = (valueProp ?? inner).slice(0, length);
  const [focused, setFocused] = React.useState(false);
  const inputRef = React.useRef<HTMLInputElement>(null);
  const [scope, animate] = useAnimate<HTMLDivElement>();

  React.useEffect(() => {
    if (error && !reduce && scope.current) animate(scope.current, { x: [0, -8, 7, -5, 3, 0] }, { duration: 0.4 });
  }, [error, reduce, animate, scope]);

  const set = (raw: string) => {
    const next = raw.replace(/\D/g, "").slice(0, length);
    if (valueProp === undefined) setInner(next);
    onValueChange?.(next);
    if (next.length === length && next !== value) onComplete?.(next);
  };

  const active = Math.min(value.length, length - 1);

  return (
    <div
      ref={scope}
      className={cn("relative inline-flex gap-2", disabled && "opacity-50", className)}
      onClick={() => inputRef.current?.focus()}
    >
      <input
        ref={inputRef}
        value={value}
        onChange={(e) => set(e.target.value)}
        onFocus={() => setFocused(true)}
        onBlur={() => setFocused(false)}
        onSelect={(e) => {
          // Keep the caret at the end so typing always fills the next slot.
          const el = e.currentTarget;
          el.setSelectionRange(el.value.length, el.value.length);
        }}
        inputMode="numeric"
        autoComplete="one-time-code"
        pattern="\d*"
        maxLength={length}
        disabled={disabled}
        aria-label={ariaLabel}
        aria-invalid={error || undefined}
        className="absolute inset-0 z-10 cursor-text bg-transparent text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed"
      />
      {Array.from({ length }, (_, i) => {
        const ch = value[i];
        const isActive = focused && i === active && !(value.length === length && i === length - 1 && ch);
        return (
          <div
            key={i}
            aria-hidden
            className={cn(
              "relative grid h-14 w-11 place-items-center rounded-xl border bg-background font-mono text-2xl font-medium transition-[border-color,box-shadow] duration-150 sm:w-12",
              error ? "border-destructive text-destructive" : isActive ? "border-foreground/50 ring-[3px] ring-ring/25" : "border-input",
            )}
          >
            <AnimatePresence mode="popLayout" initial={false}>
              {ch && (
                <motion.span
                  key={ch + i}
                  initial={{ opacity: 0, y: reduce ? 0 : 10, scale: reduce ? 1 : 0.6, filter: "blur(4px)" }}
                  animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
                  exit={{ opacity: 0, y: reduce ? 0 : -8, scale: 0.8, filter: "blur(4px)" }}
                  transition={{ type: "spring", stiffness: 600, damping: 26 }}
                >
                  {ch}
                </motion.span>
              )}
            </AnimatePresence>
            {isActive && !ch && (
              <motion.span
                className="absolute h-6 w-0.5 rounded-full bg-foreground"
                animate={{ opacity: [1, 1, 0, 0] }}
                transition={{ duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1] }}
              />
            )}
          </div>
        );
      })}
    </div>
  );
}
```