Input OTP
Accessible one-time password input for authentication.
The Input OTP component provides a user-friendly way to enter one-time passwords. It features automatic focus management, paste support, and keyboard navigation. Built from scratch using React and native HTML elements. No dependencies on any UI library.
Code
TypeScript: Copy this code into components/ui/input-otp.tsx:
tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface InputOTPProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
length?: number
value?: string
onChange?: (value: string) => void
disabled?: boolean
autoFocus?: boolean
}
const InputOTP = React.forwardRef<HTMLDivElement, InputOTPProps>(
({ className, length = 6, value = "", onChange, disabled = false, autoFocus = false, ...props }, ref) => {
const [values, setValues] = React.useState<string[]>(() => {
const initial = value.split("").slice(0, length)
return Array.from({ length }, (_, i) => initial[i] || "")
})
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([])
React.useEffect(() => {
const newValues = value.split("").slice(0, length)
const padded = Array.from({ length }, (_, i) => newValues[i] || "")
setValues(padded)
}, [value, length])
const handleChange = (index: number, newValue: string) => {
if (disabled) return
const digit = newValue.slice(-1)
if (digit && !/^d$/.test(digit)) return
const newValues = [...values]
newValues[index] = digit
setValues(newValues)
const otpValue = newValues.join("")
onChange?.(otpValue)
if (digit && index < length - 1) {
inputRefs.current[index + 1]?.focus()
}
}
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return
if (e.key === "Backspace") {
if (!values[index] && index > 0) {
inputRefs.current[index - 1]?.focus()
} else {
const newValues = [...values]
newValues[index] = ""
setValues(newValues)
const otpValue = newValues.join("")
onChange?.(otpValue)
}
} else if (e.key === "ArrowLeft" && index > 0) {
e.preventDefault()
inputRefs.current[index - 1]?.focus()
} else if (e.key === "ArrowRight" && index < length - 1) {
e.preventDefault()
inputRefs.current[index + 1]?.focus()
}
}
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
if (disabled) return
e.preventDefault()
const pastedData = e.clipboardData.getData("text").slice(0, length)
const digits = pastedData.split("").filter(char => /^d$/.test(char)).slice(0, length)
const newValues = Array.from({ length }, (_, i) => digits[i] || "")
setValues(newValues)
const otpValue = newValues.join("")
onChange?.(otpValue)
const nextIndex = Math.min(digits.length, length - 1)
inputRefs.current[nextIndex]?.focus()
}
const handleFocus = (index: number) => {
inputRefs.current[index]?.select()
}
React.useEffect(() => {
if (autoFocus && inputRefs.current[0]) {
inputRefs.current[0].focus()
}
}, [autoFocus])
return (
<div
ref={ref}
className={cn("flex items-center gap-2", className)}
{...props}
>
{Array.from({ length }).map((_, index) => (
<React.Fragment key={index}>
<input
ref={(el) => {
inputRefs.current[index] = el
}}
type="text"
inputMode="numeric"
maxLength={1}
value={values[index]}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={handlePaste}
onFocus={() => handleFocus(index)}
disabled={disabled}
className={cn(
"flex h-12 w-12 rounded-md border-2 border-foreground bg-background text-center text-lg font-bold ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 neobrutalism-shadow",
className
)}
aria-label={`Digit ${index + 1} of ${length}`}
/>
{index === 2 && (
<span className="text-foreground text-xl font-bold">.</span>
)}
</React.Fragment>
))}
</div>
)
}
)
InputOTP.displayName = "InputOTP"
export { InputOTP }JavaScript: Copy this code into components/ui/input-otp.jsx:
jsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef(
({ className, length = 6, value = "", onChange, disabled = false, autoFocus = false, ...props }, ref) => {
const [values, setValues] = React.useState(() => {
const initial = value.split("").slice(0, length)
return Array.from({ length }, (_, i) => initial[i] || "")
})
const inputRefs = React.useRef([])
React.useEffect(() => {
const newValues = value.split("").slice(0, length)
const padded = Array.from({ length }, (_, i) => newValues[i] || "")
setValues(padded)
}, [value, length])
const handleChange = (index, newValue) => {
if (disabled) return
const digit = newValue.slice(-1)
if (digit && !/^d$/.test(digit)) return
const newValues = [...values]
newValues[index] = digit
setValues(newValues)
const otpValue = newValues.join("")
onChange?.(otpValue)
if (digit && index < length - 1) {
inputRefs.current[index + 1]?.focus()
}
}
const handleKeyDown = (index, e) => {
if (disabled) return
if (e.key === "Backspace") {
if (!values[index] && index > 0) {
inputRefs.current[index - 1]?.focus()
} else {
const newValues = [...values]
newValues[index] = ""
setValues(newValues)
const otpValue = newValues.join("")
onChange?.(otpValue)
}
} else if (e.key === "ArrowLeft" && index > 0) {
e.preventDefault()
inputRefs.current[index - 1]?.focus()
} else if (e.key === "ArrowRight" && index < length - 1) {
e.preventDefault()
inputRefs.current[index + 1]?.focus()
}
}
const handlePaste = (e) => {
if (disabled) return
e.preventDefault()
const pastedData = e.clipboardData.getData("text").slice(0, length)
const digits = pastedData.split("").filter(char => /^d$/.test(char)).slice(0, length)
const newValues = Array.from({ length }, (_, i) => digits[i] || "")
setValues(newValues)
const otpValue = newValues.join("")
onChange?.(otpValue)
const nextIndex = Math.min(digits.length, length - 1)
inputRefs.current[nextIndex]?.focus()
}
const handleFocus = (index) => {
inputRefs.current[index]?.select()
}
React.useEffect(() => {
if (autoFocus && inputRefs.current[0]) {
inputRefs.current[0].focus()
}
}, [autoFocus])
return (
<div
ref={ref}
className={cn("flex items-center gap-2", className)}
{...props}
>
{Array.from({ length }).map((_, index) => (
<React.Fragment key={index}>
<input
ref={(el) => {
inputRefs.current[index] = el
}}
type="text"
inputMode="numeric"
maxLength={1}
value={values[index]}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={handlePaste}
onFocus={() => handleFocus(index)}
disabled={disabled}
className={cn(
"flex h-12 w-12 rounded-md border-2 border-foreground bg-background text-center text-lg font-bold ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 neobrutalism-shadow",
className
)}
aria-label={`Digit ${index + 1} of ${length}`}
/>
{index === 2 && (
<span className="text-foreground text-xl font-bold">.</span>
)}
</React.Fragment>
))}
</div>
)
}
)
InputOTP.displayName = "InputOTP"
export { InputOTP }Usage
TypeScript:
tsx
import { InputOTP } from "@/components/ui/input-otp"
import * as React from "react"
function MyComponent() {
const [otp, setOtp] = React.useState("")
return (
<InputOTP
length={6}
value={otp}
onChange={setOtp}
autoFocus
/>
)
}JavaScript:
jsx
import { InputOTP } from "@/components/ui/input-otp"
import * as React from "react"
function MyComponent() {
const [otp, setOtp] = React.useState("")
return (
<InputOTP
length={6}
value={otp}
onChange={setOtp}
autoFocus
/>
)
}Make sure you also have the lib/utils.ts file with the cn helper function.
Examples
Default
.
Disabled
.
Custom Length (4 digits)
.