Input OTP
A one-time password input component for verification codes with support for grouping, separators, and pattern validation.
Installation
Install the component Input OTP in your project using the CLI.
Input OTP.tsx
pnpm dlx behsseui@latest add InputOTPInstall the component manually.
Create a ui folder at the root of the project, then a component folder inside it, and finally a Input OTP.tsx file in that folder.
Copy and paste the following code into your project.
ui/components/InputOTP.tsx
1"use client"23import type { ReactNode, HTMLAttributes } from "react"4import { createContext, useContext, useState, useRef, useCallback, forwardRef } from "react"5import { cn } from "@/lib/utils"67// Context8type InputOTPContextType = {9 value: string10 maxLength: number11 disabled: boolean12 activeIndex: number13 isFocused: boolean14 focusInput: () => void15 placeholder?: string16}1718const InputOTPContext = createContext<InputOTPContextType | undefined>(undefined)1920function useInputOTP() {21 const context = useContext(InputOTPContext)22 if (!context) {23 throw new Error("InputOTP components must be used within an InputOTP")24 }25 return context26}2728// Root29type InputOTPProps = {30 children: ReactNode31 maxLength: number32 value?: string33 defaultValue?: string34 onChange?: (value: string) => void35 onComplete?: (value: string) => void36 disabled?: boolean37 pattern?: string38 placeholder?: string39 className?: string40 name?: string41 autoFocus?: boolean42} & Omit<HTMLAttributes<HTMLDivElement>, "onChange">4344const InputOTP = forwardRef<HTMLInputElement, InputOTPProps>(45 (46 {47 children,48 maxLength,49 value: controlledValue,50 defaultValue = "",51 onChange,52 onComplete,53 disabled = false,54 pattern = "[0-9]",55 placeholder,56 className,57 name,58 autoFocus = false,59 ...props60 },61 ref62 ) => {63 const [internalValue, setInternalValue] = useState(defaultValue)64 const [isFocused, setIsFocused] = useState(false)65 const inputRef = useRef<HTMLInputElement>(null)6667 const setRefs = useCallback(68 (node: HTMLInputElement | null) => {69 (inputRef as React.MutableRefObject<HTMLInputElement | null>).current = node70 if (typeof ref === "function") {71 ref(node)72 } else if (ref) {73 (ref as React.MutableRefObject<HTMLInputElement | null>).current = node74 }75 },76 [ref]77 )7879 const isControlled = controlledValue !== undefined80 const value = isControlled ? controlledValue : internalValue8182 const patternRegex = new RegExp(`^${pattern}$`)8384 const activeIndex = value.length < maxLength ? value.length : maxLength - 18586 const focusInput = useCallback(() => {87 if (!disabled) {88 inputRef.current?.focus()89 }90 }, [disabled])9192 const handleChange = useCallback(93 (newValue: string) => {94 const filtered = newValue95 .split("")96 .filter((char) => patternRegex.test(char))97 .join("")98 .slice(0, maxLength)99100 if (!isControlled) {101 setInternalValue(filtered)102 }103 onChange?.(filtered)104105 if (filtered.length === maxLength) {106 onComplete?.(filtered)107 }108 },109 [isControlled, maxLength, onChange, onComplete, patternRegex]110 )111112 const handleInputChange = useCallback(113 (e: React.ChangeEvent<HTMLInputElement>) => {114 handleChange(e.target.value)115 },116 [handleChange]117 )118119 const handleKeyDown = useCallback(120 (e: React.KeyboardEvent<HTMLInputElement>) => {121 if (122 e.key === "Backspace" ||123 e.key === "Delete" ||124 e.key === "ArrowLeft" ||125 e.key === "ArrowRight" ||126 e.key === "Tab" ||127 e.ctrlKey ||128 e.metaKey129 ) {130 return131 }132133 if (!patternRegex.test(e.key)) {134 e.preventDefault()135 }136 },137 [patternRegex]138 )139140 const handlePaste = useCallback(141 (e: React.ClipboardEvent<HTMLInputElement>) => {142 e.preventDefault()143 const pasted = e.clipboardData.getData("text")144 handleChange(pasted)145 },146 [handleChange]147 )148149 const handleFocus = useCallback(() => {150 setIsFocused(true)151 }, [])152153 const handleBlur = useCallback(() => {154 setIsFocused(false)155 }, [])156157 return (158 <InputOTPContext.Provider159 value={{160 value,161 maxLength,162 disabled,163 activeIndex,164 isFocused,165 focusInput,166 placeholder,167 }}168 >169 <div170 className={cn("flex items-center gap-2", disabled && "opacity-50 cursor-not-allowed", className)}171 onClick={focusInput}172 data-disabled={disabled ? "" : undefined}173 {...props}174 >175 {children}176 <input177 ref={setRefs}178 type="text"179 inputMode="numeric"180 autoComplete="one-time-code"181 name={name}182 value={value}183 onChange={handleInputChange}184 onKeyDown={handleKeyDown}185 onPaste={handlePaste}186 onFocus={handleFocus}187 onBlur={handleBlur}188 disabled={disabled}189 maxLength={maxLength}190 autoFocus={autoFocus}191 className="sr-only"192 aria-label={`OTP input, ${maxLength} digits`}193 tabIndex={0}194 />195 </div>196 </InputOTPContext.Provider>197 )198 }199)200InputOTP.displayName = "InputOTP"201202// Group203type InputOTPGroupProps = {204 children: ReactNode205 className?: string206} & HTMLAttributes<HTMLDivElement>207208function InputOTPGroup({ children, className, ...props }: InputOTPGroupProps) {209 return (210 <div className={cn("flex items-center", className)} {...props}>211 {children}212 </div>213 )214}215216// Slot217type InputOTPSlotProps = {218 index: number219 className?: string220} & HTMLAttributes<HTMLDivElement>221222function InputOTPSlot({ index, className, ...props }: InputOTPSlotProps) {223 const { value, activeIndex, isFocused, disabled, focusInput, placeholder } = useInputOTP()224225 const char = value[index] ?? ""226 const isActive = isFocused && index === activeIndex227 const isFilled = char !== ""228229 return (230 <div231 className={cn(232 "relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm font-medium transition-all",233 "first:rounded-l-md first:border-l last:rounded-r-md",234 isActive && "z-10 ring-2 ring-ring",235 disabled && "cursor-not-allowed",236 className237 )}238 data-active={isActive ? "" : undefined}239 data-filled={isFilled ? "" : undefined}240 onClick={focusInput}241 {...props}242 >243 {isFilled ? (244 char245 ) : placeholder ? (246 <span className="text-muted-foreground">{placeholder}</span>247 ) : null}248 {isActive && !isFilled && (249 <span className="pointer-events-none absolute inset-0 flex items-center justify-center">250 <span className="h-4 w-px animate-caret-blink bg-foreground" />251 </span>252 )}253 </div>254 )255}256257// Separator258type InputOTPSeparatorProps = {259 children?: ReactNode260 className?: string261} & HTMLAttributes<HTMLDivElement>262263function InputOTPSeparator({ children, className, ...props }: InputOTPSeparatorProps) {264 return (265 <div266 role="separator"267 className={cn("flex items-center justify-center px-1 text-muted-foreground", className)}268 {...props}269 >270 {children ?? (271 <svg width="8" height="2" viewBox="0 0 8 2" fill="none">272 <rect width="8" height="2" rx="1" fill="currentColor" />273 </svg>274 )}275 </div>276 )277}278279export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }280Usages
Different variants and use cases for the Input OTP component.
Default
A 6-digit OTP input in a single group.
Default.tsx
<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>With Separator
Two groups of 3 digits separated by a dash.
With Separator.tsx
<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>Pattern
Accept alphanumeric characters instead of digits only.
Pattern.tsx
<InputOTP maxLength={6} pattern="[0-9a-zA-Z]">
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>Disabled
A disabled OTP input that cannot be interacted with.
Disabled.tsx
<InputOTP maxLength={6} disabled>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>With Placeholder
Show dots as placeholder in empty slots.
With Placeholder.tsx
<InputOTP maxLength={6} placeholder="●">
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>Controlled
Fully controlled OTP input with external state management.
Value: (empty)
Controlled.tsx
const [value, setValue] = useState("")
<InputOTP maxLength={6} value={value} onChange={setValue}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<p className="text-sm text-muted-foreground">Value: {value || "(empty)"}</p>