# OTP Input

> One-time code input with paste support, grouping, masking and keyboard navigation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/otp-input
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/otp-input.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/otp-input-demo.tsx"
"use client"

import * as React from "react"

import { OtpInput } from "@/components/otp-input"

export default function OtpInputDemo() {
  const [status, setStatus] = React.useState<"idle" | "ok" | "wrong">("idle")
  return (
    <div className="flex flex-col items-center gap-4 text-center">
      <div>
        <h2 className="text-lg font-semibold">Check your email</h2>
        <p className="mt-1 text-sm text-muted-foreground">
          Enter the 6-digit code we sent you. Try <span className="font-mono text-foreground">424242</span>.
        </p>
      </div>
      <OtpInput
        invalid={status === "wrong"}
        onChange={() => setStatus("idle")}
        onComplete={(code) => setStatus(code === "424242" ? "ok" : "wrong")}
      />
      <p className="h-5 text-sm" role="status">
        {status === "ok" && <span className="text-emerald-600 dark:text-emerald-400">Verified, welcome back.</span>}
        {status === "wrong" && <span className="text-destructive">That code doesn&apos;t match.</span>}
      </p>
    </div>
  )
}
```

## Source

```tsx title="components/otp-input.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface OtpInputProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  length?: number
  value?: string
  defaultValue?: string
  onChange?: (value: string) => void
  /** Called once every slot is filled */
  onComplete?: (value: string) => void
  /** Insert a separator after every N slots, 0 for none */
  groupSize?: number
  /** Show dots instead of the digits */
  mask?: boolean
  /** Accept letters as well as digits */
  alphanumeric?: boolean
  invalid?: boolean
  disabled?: boolean
}

export function OtpInput({
  length = 6,
  value,
  defaultValue = "",
  onChange,
  onComplete,
  groupSize = 3,
  mask = false,
  alphanumeric = false,
  invalid = false,
  disabled = false,
  className,
  ...props
}: OtpInputProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const code = (value ?? internal).slice(0, length)
  const inputs = React.useRef<(HTMLInputElement | null)[]>([])
  const allowed = alphanumeric ? /[a-z0-9]/i : /\d/

  const commit = (next: string) => {
    next = next.slice(0, length)
    setInternal(next)
    onChange?.(next)
    if (next.length === length) onComplete?.(next)
  }

  const focus = (index: number) => inputs.current[Math.max(0, Math.min(length - 1, index))]?.focus()

  const insert = (index: number, text: string) => {
    const chars = [...text].filter((c) => allowed.test(c)).map((c) => c.toUpperCase())
    if (!chars.length) return
    // Slots fill left to right, so typing into a later empty slot lands at the end
    const start = Math.min(index, code.length)
    commit(code.slice(0, start) + chars.join("") + code.slice(start + chars.length))
    focus(start + chars.length)
  }

  const onKeyDown = (index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Backspace") {
      event.preventDefault()
      const target = code[index] ? index : index - 1
      if (target < 0) return
      commit(code.slice(0, target) + code.slice(target + 1))
      focus(target)
    } else if (event.key === "ArrowLeft") {
      event.preventDefault()
      focus(index - 1)
    } else if (event.key === "ArrowRight") {
      event.preventDefault()
      focus(Math.min(index + 1, code.length))
    }
  }

  return (
    <div
      role="group"
      aria-label="One-time code"
      className={cn("flex items-center gap-2", disabled && "opacity-50", className)}
      {...props}
    >
      {Array.from({ length }, (_, index) => {
        const char = code[index] ?? ""
        const active = index === Math.min(code.length, length - 1)
        return (
          <React.Fragment key={index}>
            {groupSize > 0 && index > 0 && index % groupSize === 0 && (
              <span aria-hidden className="h-0.5 w-3 rounded-full bg-muted-foreground/40" />
            )}
            <input
              ref={(el) => {
                inputs.current[index] = el
              }}
              value={mask && char ? "•" : char}
              onChange={(event) => {
                const typed = event.target.value.replace("•", "")
                // Typing next to an existing character replaces it
                insert(index, char && !mask && typed.length === 2 ? typed.replace(char, "") : typed)
              }}
              onKeyDown={(event) => onKeyDown(index, event)}
              onPaste={(event) => {
                event.preventDefault()
                insert(index, event.clipboardData.getData("text"))
              }}
              onFocus={(event) => {
                // Keep the caret on the first empty slot
                if (index > code.length) focus(code.length)
                else event.target.select()
              }}
              inputMode={alphanumeric ? "text" : "numeric"}
              autoComplete={index === 0 ? "one-time-code" : "off"}
              aria-label={`Character ${index + 1} of ${length}`}
              aria-invalid={invalid}
              disabled={disabled}
              tabIndex={active ? 0 : -1}
              className={cn(
                "size-12 rounded-xl border bg-background text-center text-lg font-semibold caret-transparent shadow-xs transition-[border-color,box-shadow] outline-none focus:border-ring focus:ring-3 focus:ring-ring/20",
                char && "border-foreground/30",
                invalid && "border-destructive focus:border-destructive focus:ring-destructive/20"
              )}
            />
          </React.Fragment>
        )
      })}
    </div>
  )
}
```
