main
import { useRef, useState } from 'react'
export interface OtpInputProps {
value: string
onChange: (value: string) => void
length?: number
disabled?: boolean
'aria-label'?: string
}
/** Segmented one-time-code field: 6 cells, dark-bordered active cell with orange caret. */
export function OtpInput({ value, onChange, length = 6, disabled, ...aria }: OtpInputProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [focused, setFocused] = useState(false)
return (
<div className="relative flex w-full gap-1.5 sm:gap-2.5" onClick={() => inputRef.current?.focus()}>
{Array.from({ length }, (_, i) => {
const digit = value[i] ?? ''
const isActive = focused && i === value.length && value.length < length
return (
<div
key={i}
className={`flex h-[58px] flex-1 items-center justify-center rounded-field border bg-card font-mono text-[22px] font-bold text-foreground transition-colors ${
isActive ? 'border-foreground' : 'border-input'
}`}
>
{digit || (isActive && <span className="h-6 w-[2px] animate-pulse bg-primary" />)}
</div>
)
})}
<input
ref={inputRef}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={length}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className="absolute inset-0 size-full cursor-default opacity-0"
{...aria}
/>
</div>
)
}