shadimage

OTP Input

Verification code field with auto-focus movement and paste detection.

components/ui/otp-input.tsx

import * as React from 'react'
import { cn } from '@/lib/utils'

export interface OTPInputProps {
  length?: number
  value?: string
  defaultValue?: string
  onChange?: (value: string) => void
  onComplete?: (value: string) => void
  disabled?: boolean
  className?: string
  ariaLabel?: string
}

export function OTPInput({
  length = 4,
  value,
  defaultValue = '',
  onChange,
  onComplete,
  disabled = false,
  className,
  ariaLabel = 'One-time code',
}: OTPInputProps) {
  const isControlled = value !== undefined
  const [internal, setInternal] = React.useState(defaultValue)
  const current = (isControlled ? value : internal) ?? ''
  const inputRefs = React.useRef<Array<HTMLInputElement | null>>([])
  const completedRef = React.useRef(false)

  const setValue = (next: string) => {
    const trimmed = next.slice(0, length)
    if (!isControlled) setInternal(trimmed)
    onChange?.(trimmed)
    if (trimmed.length === length) {
      if (!completedRef.current) {
        completedRef.current = true
        onComplete?.(trimmed)
      }
    } else {
      completedRef.current = false
    }
  }

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

  const handleChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
    const char = event.target.value.replace(/\D/g, '').slice(-1)
    const chars = current.split('')
    chars[index] = char
    setValue(chars.join(''))
    if (char) focusIndex(index + 1)
  }

  const handleKeyDown = (index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'Backspace') {
      event.preventDefault()
      if (current[index]) {
        const chars = current.split('')
        chars[index] = ''
        setValue(chars.join(''))
      } else {
        focusIndex(index - 1)
      }
    } else if (event.key === 'ArrowLeft') {
      focusIndex(index - 1)
    } else if (event.key === 'ArrowRight') {
      focusIndex(index + 1)
    }
  }

  const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
    event.preventDefault()
    const pasted = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, length)
    if (pasted) {
      setValue(pasted)
      focusIndex(Math.min(pasted.length, length - 1))
    }
  }

  return (
    <div className={cn('flex items-center gap-2 font-sans', className)} role="group" aria-label={ariaLabel}>
      {Array.from({ length }).map((_, index) => (
        <input
          key={index}
          ref={(el) => {
            inputRefs.current[index] = el
          }}
          type="text"
          inputMode="numeric"
          autoComplete={index === 0 ? 'one-time-code' : 'off'}
          aria-label={`${ariaLabel} digit ${index + 1}`}
          value={current[index] ?? ''}
          disabled={disabled}
          onChange={(event) => handleChange(index, event)}
          onKeyDown={(event) => handleKeyDown(index, event)}
          onPaste={handlePaste}
          className={cn(
            'h-12 w-11 rounded-xl bg-rk-surface text-center font-mono text-lg font-black text-rk-ink rk-border-sm rk-shadow-sm transition-all outline-none',
            'focus:bg-rk-primary/40 focus:ring-4 focus:ring-black/15',
            'disabled:opacity-50',
            current[index] && 'bg-rk-primary/25'
          )}
        />
      ))}
    </div>
  )
}
Visual style
terminal-mono

DependenciesBundle weight · light

clsxtailwind-merge