# OTP Input

> One-time code input with auto-advance, paste and backspace handling.

Category: Forms. Docs: https://get-neonkit.vercel.app/components/otp-input

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/otp-input.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/otp-input`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { OTPInput } from "@/components/ui/otp-input"

<OTPInput onComplete={(code) => verify(code)} />
```

## Examples

### Verification

onComplete fires once every slot is filled. Try pasting a code.

```tsx
"use client";

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

type Status = "idle" | "verifying" | "verified" | "resent";

export default function OTPInputDemo() {
  const [status, setStatus] = React.useState<Status>("idle");
  const timer = React.useRef<number>(undefined);

  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  function verify() {
    setStatus("verifying");
    timer.current = window.setTimeout(() => setStatus("verified"), 900);
  }

  return (
    <div className="grid justify-items-center gap-5 text-center">
      <div className="grid gap-1">
        <p className="text-sm font-medium text-white/90">Check your inbox</p>
        <p className="text-sm text-white/55">
          Enter the 6-digit code we sent to <span className="text-white/80">ada@northwind.io</span>
        </p>
      </div>
      <OTPInput
        aria-label="Verification code"
        color={status === "verified" ? "green" : undefined}
        onValueChange={() => {
          window.clearTimeout(timer.current);
          setStatus("idle");
        }}
        onComplete={verify}
      />
      <p aria-live="polite" className="h-5 text-xs leading-5 text-white/55">
        {status === "verifying" && <span className="animate-pulse">Verifying…</span>}
        {status === "verified" && <span className="text-[#34d399]">Verified. Signing you in.</span>}
        {status === "resent" && "A new code is on its way."}
        {status === "idle" && (
          <>
            Didn&apos;t get it?{" "}
            <button
              type="button"
              onClick={() => setStatus("resent")}
              className="cursor-pointer font-medium text-white/80 underline-offset-4 outline-none transition-colors hover:text-white hover:underline focus-visible:underline"
            >
              Resend code
            </button>
          </>
        )}
      </p>
    </div>
  );
}
```

### Grouped

groupSize splits the slots with a dash, so long codes are easier to read back.

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

export default function OTPInputGrouped() {
  return (
    <div className="grid justify-items-center gap-4 text-center">
      <div className="grid gap-1">
        <p className="text-sm font-medium text-white/90">Pair your TV</p>
        <p className="text-sm text-white/55">Type the code shown on the screen.</p>
      </div>
      <OTPInput aria-label="Pairing code" groupSize={3} size="lg" />
    </div>
  );
}
```

### Masked PIN

mask shows dots instead of digits.

```tsx
"use client";

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

export default function OTPInputPin() {
  const [approved, setApproved] = React.useState(false);

  return (
    <div className="grid justify-items-center gap-4 text-center">
      <div className="grid gap-1">
        <p className="text-sm font-medium text-white/90">Approve production deploy</p>
        <p className="text-sm text-white/55">Enter your 4-digit PIN to ship v2.4.0.</p>
      </div>
      <OTPInput
        aria-label="PIN"
        length={4}
        mask
        onValueChange={() => setApproved(false)}
        onComplete={() => setApproved(true)}
      />
      <p aria-live="polite" className="h-5 text-xs leading-5 text-[#34d399]">
        {approved && "Approved. Deploy started."}
      </p>
    </div>
  );
}
```

### Invalid code

The row shakes and turns red each time invalid switches on.

```tsx
"use client";

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

type Status = "idle" | "checking" | "invalid" | "accepted";

export default function OTPInputInvalid() {
  const [status, setStatus] = React.useState<Status>("idle");
  const timer = React.useRef<number>(undefined);
  const messageId = React.useId();

  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  function check(code: string) {
    setStatus("checking");
    timer.current = window.setTimeout(() => {
      setStatus(code === "424242" ? "accepted" : "invalid");
    }, 500);
  }

  return (
    <div className="grid justify-items-center gap-4 text-center">
      <div className="grid gap-1">
        <p className="text-sm font-medium text-white/90">Two-factor authentication</p>
        <p className="text-sm text-white/55">
          Open your authenticator app. Anything but <span className="font-mono text-white/80">424242</span> fails.
        </p>
      </div>
      <OTPInput
        aria-label="Authenticator code"
        invalid={status === "invalid"}
        aria-describedby={messageId}
        onValueChange={() => {
          window.clearTimeout(timer.current);
          setStatus("idle");
        }}
        onComplete={check}
      />
      <p
        id={messageId}
        aria-live="polite"
        className={status === "accepted" ? "h-5 text-xs leading-5 text-[#34d399]" : "h-5 text-xs leading-5 text-[#ff4d5e]"}
      >
        {status === "invalid" && "That code didn’t work. Check your app and try again."}
        {status === "accepted" && "Code accepted."}
      </p>
    </div>
  );
}
```

### Letters and numbers

mode="alphanumeric" accepts letters too and brings up the full keyboard on phones.

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

export default function OTPInputAlphanumeric() {
  return (
    <div className="grid justify-items-center gap-4 text-center">
      <div className="grid gap-1">
        <p className="text-sm font-medium text-white/90">Use a recovery code</p>
        <p className="text-sm text-white/55">One of the 8-character codes you saved when you set up 2FA.</p>
      </div>
      <OTPInput aria-label="Recovery code" mode="alphanumeric" length={8} groupSize={4} size="sm" />
    </div>
  );
}
```

## API reference

### OTPInput

Other props and the ref go to the wrapping group element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `length` | `number` | `6` | Number of slots. |
| `value` | `string` | — | Controlled code. |
| `defaultValue` | `string` | — | Initial code when uncontrolled. |
| `onValueChange` | `(code: string) => void` | — | Called on every change. |
| `onComplete` | `(code: string) => void` | — | Called when the last empty slot is filled. |
| `mode` | `"numeric" \| "alphanumeric"` | `"numeric"` | Characters accepted. Also picks the mobile keyboard. |
| `groupSize` | `number` | — | Puts a dash after every groupSize slots. |
| `mask` | `boolean` | `false` | Shows dots instead of characters. |
| `invalid` | `boolean` | `false` | Red state. Shakes the row when it switches on. |
| `disabled` | `boolean` | `false` | Disables every slot. |
| `autoFocus` | `boolean` | `false` | Focuses the first empty slot on mount. |
| `name` | `string` | — | Submits the code with a surrounding form. |
| `aria-label` | `string` | `"One-time code"` | Names the group of slots. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Slot size and text size. |

## Accessibility

- Each slot is a native input labelled "Digit 2 of 6" inside a named group.
- The first slot uses autocomplete="one-time-code", so codes from SMS can fill every slot in one tap.
- Pasting fills from the focused slot. A full-length code always fills from the start, and spaces or dashes are dropped.
- Only the next slot to fill is in the tab order. Arrow keys, Home and End move between slots.
- Backspace clears the focused slot, or steps back and clears the previous one when the focused slot is empty.
- Link an error message with aria-describedby when invalid is on. The shake is skipped when reduced motion is on.

## Source

`components/ui/otp-input.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const INVALID = "#ff4d5e";

const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;

const sizes = {
  sm: { root: "gap-1.5", slot: "h-9 w-8 rounded-md text-sm", caret: "h-3.5", dot: "size-1.5", dash: "w-2" },
  md: { root: "gap-2", slot: "h-12 w-10 rounded-lg text-lg", caret: "h-5", dot: "size-2", dash: "w-2.5" },
  lg: { root: "gap-2.5", slot: "h-14 w-12 rounded-xl text-2xl", caret: "h-6", dot: "size-2.5", dash: "w-3" },
} as const;

const SHAKE: Keyframe[] = [0, -7, 6, -4, 3, -1, 0].map((x) => ({
  transform: `translateX(${x}px)`,
}));

function sanitize(text: string, mode: "numeric" | "alphanumeric") {
  return (text.match(mode === "numeric" ? /\d/g : /[a-z0-9]/gi) ?? []).join("");
}

export interface OTPInputProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "defaultValue" | "onChange"> {
  /** Number of slots. */
  length?: number;
  value?: string;
  defaultValue?: string;
  onValueChange?: (code: string) => void;
  /** Called once every slot is filled. */
  onComplete?: (code: string) => void;
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  size?: keyof typeof sizes;
  /** Which characters are accepted. Also picks the mobile keyboard. */
  mode?: "numeric" | "alphanumeric";
  /** Split the slots into groups of this size with a dash between them. */
  groupSize?: number;
  /** Show dots instead of the characters. */
  mask?: boolean;
  disabled?: boolean;
  /** Red state. The row shakes each time this turns on. */
  invalid?: boolean;
  /** Focus the first empty slot on mount. */
  autoFocus?: boolean;
  /** Submits the code under this name with a surrounding form. */
  name?: string;
}

const OTPInput = React.forwardRef<HTMLDivElement, OTPInputProps>(
  (
    {
      length = 6,
      value,
      defaultValue = "",
      onValueChange,
      onComplete,
      color,
      size = "md",
      mode = "numeric",
      groupSize,
      mask = false,
      disabled = false,
      invalid = false,
      autoFocus = false,
      name,
      className,
      style,
      "aria-label": ariaLabel = "One-time code",
      ...props
    },
    ref,
  ) => {
    const rootRef = React.useRef<HTMLDivElement>(null);
    const inputs = React.useRef<(HTMLInputElement | null)[]>([]);
    React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);

    const [uncontrolled, setUncontrolled] = React.useState(() =>
      sanitize(defaultValue, mode).slice(0, length),
    );
    const code = sanitize(value ?? uncontrolled, mode).slice(0, length);
    const cursor = Math.min(code.length, length - 1);
    const s = sizes[size];
    const unit = mode === "numeric" ? "Digit" : "Character";

    // Handlers read the latest code from here, since focus moves before React re-renders.
    const codeRef = React.useRef(code);
    useIsomorphicLayoutEffect(() => {
      codeRef.current = code;
    });

    // Keep the focused slot's character selected so the next keystroke replaces it.
    useIsomorphicLayoutEffect(() => {
      const active = document.activeElement;
      if (active instanceof HTMLInputElement && inputs.current.includes(active)) active.select();
    }, [code]);

    const wasInvalid = React.useRef(invalid);
    React.useEffect(() => {
      if (invalid && !wasInvalid.current) {
        const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        if (!reduce) {
          rootRef.current?.animate?.(SHAKE, {
            duration: 420,
            easing: "cubic-bezier(0.36, 0.07, 0.19, 0.97)",
          });
        }
      }
      wasInvalid.current = invalid;
    }, [invalid]);

    function focusSlot(index: number) {
      const target = Math.max(0, Math.min(index, codeRef.current.length, length - 1));
      const input = inputs.current[target];
      input?.focus();
      input?.select();
    }

    function update(next: string, focusIndex: number) {
      const previous = codeRef.current;
      codeRef.current = next;
      if (next !== previous) {
        if (value === undefined) setUncontrolled(next);
        onValueChange?.(next);
        if (next.length === length) onComplete?.(next);
      }
      focusSlot(focusIndex);
    }

    function fill(index: number, chars: string) {
      // A full code replaces everything, wherever it was pasted.
      const start = chars.length >= length ? 0 : index;
      const current = codeRef.current;
      const next = (current.slice(0, start) + chars + current.slice(start + chars.length)).slice(
        0,
        length,
      );
      update(next, start + chars.length);
    }

    function handleChange(index: number, event: React.ChangeEvent<HTMLInputElement>) {
      const current = codeRef.current;
      const raw = event.target.value;
      const previous = current[index] ?? "";
      if (!raw) {
        if (previous) update(current.slice(0, index) + current.slice(index + 1), index);
        return;
      }
      // Typing into a filled slot without a selection gives "old + new". Keep the new one.
      const typed = previous && raw.length === 2 && raw.includes(previous) ? raw.replace(previous, "") : raw;
      const chars = sanitize(typed, mode);
      if (chars) fill(index, chars);
    }

    function handleKeyDown(index: number, event: React.KeyboardEvent<HTMLInputElement>) {
      const current = codeRef.current;
      switch (event.key) {
        case "Backspace":
          event.preventDefault();
          if (current[index]) update(current.slice(0, index) + current.slice(index + 1), index);
          else if (index > 0) update(current.slice(0, index - 1) + current.slice(index), index - 1);
          break;
        case "Delete":
          event.preventDefault();
          if (current[index]) update(current.slice(0, index) + current.slice(index + 1), index);
          break;
        case "ArrowLeft":
          event.preventDefault();
          focusSlot(index - 1);
          break;
        case "ArrowRight":
          event.preventDefault();
          focusSlot(index + 1);
          break;
        case "Home":
          event.preventDefault();
          focusSlot(0);
          break;
        case "End":
          event.preventDefault();
          focusSlot(length - 1);
          break;
      }
    }

    return (
      <div
        ref={rootRef}
        role="group"
        aria-label={ariaLabel}
        aria-disabled={disabled || undefined}
        data-invalid={invalid || undefined}
        style={neonVars(invalid ? INVALID : color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "inline-flex items-center",
          s.root,
          className,
        )}
        {...props}
      >
        {Array.from({ length }, (_, index) => {
          const char = code[index] ?? "";
          return (
            <React.Fragment key={index}>
              {groupSize && index > 0 && index % groupSize === 0 ? (
                <span
                  aria-hidden
                  className={cn("h-0.5 shrink-0 rounded-full bg-[rgb(var(--neon)/0.45)]", s.dash)}
                />
              ) : null}
              <div className="relative shrink-0">
                <input
                  ref={(node) => {
                    inputs.current[index] = node;
                  }}
                  type={mask ? "password" : "text"}
                  inputMode={mode === "numeric" ? "numeric" : "text"}
                  pattern={mode === "numeric" ? "[0-9]*" : undefined}
                  autoComplete={index === 0 ? "one-time-code" : "off"}
                  autoCapitalize="off"
                  autoCorrect="off"
                  spellCheck={false}
                  // The first slot takes a whole code so SMS autofill can land in one go.
                  maxLength={index === 0 ? length : 1}
                  value={char}
                  disabled={disabled}
                  autoFocus={autoFocus && index === cursor}
                  tabIndex={index === cursor ? 0 : -1}
                  aria-label={`${unit} ${index + 1} of ${length}`}
                  aria-invalid={invalid || undefined}
                  onChange={(event) => handleChange(index, event)}
                  onKeyDown={(event) => handleKeyDown(index, event)}
                  onPaste={(event) => {
                    event.preventDefault();
                    const chars = sanitize(event.clipboardData.getData("text"), mode);
                    if (chars) fill(index, chars);
                  }}
                  onMouseDown={(event) => {
                    // Slots fill left to right, so a click past the cursor lands on the cursor.
                    if (index > Math.min(codeRef.current.length, length - 1)) {
                      event.preventDefault();
                      focusSlot(index);
                    }
                  }}
                  onFocus={(event) => {
                    if (index > Math.min(codeRef.current.length, length - 1)) focusSlot(index);
                    else event.target.select();
                  }}
                  onClick={(event) => {
                    if (document.activeElement === event.currentTarget) event.currentTarget.select();
                  }}
                  className={cn(
                    "peer block border border-white/10 bg-white/[0.03] p-0 text-center font-medium tabular-nums text-white caret-transparent outline-none selection:bg-transparent",
                    "transition-[border-color,background-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]",
                    char
                      ? "border-[rgb(var(--neon)/0.45)] bg-[rgb(var(--neon)/0.05)] shadow-[0_0_12px_-3px_rgb(var(--neon)/0.45)]"
                      : "hover:border-white/20",
                    "focus:border-[rgb(var(--neon))] focus:bg-[rgb(var(--neon)/0.08)] focus:shadow-[0_0_0_1px_rgb(var(--neon)/0.3),0_0_22px_-2px_rgb(var(--neon)/0.6),inset_0_0_10px_rgb(var(--neon)/0.15)]",
                    "disabled:cursor-not-allowed disabled:opacity-50",
                    mask && "text-transparent",
                    s.slot,
                  )}
                />
                {!char && (
                  <span
                    aria-hidden
                    className={cn(
                      "pointer-events-none absolute left-1/2 top-1/2 hidden w-px -translate-x-1/2 -translate-y-1/2 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_6px_rgb(var(--neon))]",
                      "animate-pulse [animation-duration:1.1s] peer-focus:block motion-reduce:animate-none",
                      s.caret,
                    )}
                  />
                )}
                {mask && char && (
                  <span
                    aria-hidden
                    className={cn(
                      "pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/90",
                      s.dot,
                    )}
                  />
                )}
              </div>
            </React.Fragment>
          );
        })}
        {name && <input type="hidden" name={name} value={code} disabled={disabled} />}
      </div>
    );
  },
);
OTPInput.displayName = "OTPInput";

export { OTPInput };
```
