Docs/Components/Input OTP

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 InputOTP

Install 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"
2
3import type { ReactNode, HTMLAttributes } from "react"
4import { createContext, useContext, useState, useRef, useCallback, forwardRef } from "react"
5import { cn } from "@/lib/utils"
6
7// Context
8type InputOTPContextType = {
9 value: string
10 maxLength: number
11 disabled: boolean
12 activeIndex: number
13 isFocused: boolean
14 focusInput: () => void
15 placeholder?: string
16}
17
18const InputOTPContext = createContext<InputOTPContextType | undefined>(undefined)
19
20function useInputOTP() {
21 const context = useContext(InputOTPContext)
22 if (!context) {
23 throw new Error("InputOTP components must be used within an InputOTP")
24 }
25 return context
26}
27
28// Root
29type InputOTPProps = {
30 children: ReactNode
31 maxLength: number
32 value?: string
33 defaultValue?: string
34 onChange?: (value: string) => void
35 onComplete?: (value: string) => void
36 disabled?: boolean
37 pattern?: string
38 placeholder?: string
39 className?: string
40 name?: string
41 autoFocus?: boolean
42} & Omit<HTMLAttributes<HTMLDivElement>, "onChange">
43
44const 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 ...props
60 },
61 ref
62 ) => {
63 const [internalValue, setInternalValue] = useState(defaultValue)
64 const [isFocused, setIsFocused] = useState(false)
65 const inputRef = useRef<HTMLInputElement>(null)
66
67 const setRefs = useCallback(
68 (node: HTMLInputElement | null) => {
69 (inputRef as React.MutableRefObject<HTMLInputElement | null>).current = node
70 if (typeof ref === "function") {
71 ref(node)
72 } else if (ref) {
73 (ref as React.MutableRefObject<HTMLInputElement | null>).current = node
74 }
75 },
76 [ref]
77 )
78
79 const isControlled = controlledValue !== undefined
80 const value = isControlled ? controlledValue : internalValue
81
82 const patternRegex = new RegExp(`^${pattern}$`)
83
84 const activeIndex = value.length < maxLength ? value.length : maxLength - 1
85
86 const focusInput = useCallback(() => {
87 if (!disabled) {
88 inputRef.current?.focus()
89 }
90 }, [disabled])
91
92 const handleChange = useCallback(
93 (newValue: string) => {
94 const filtered = newValue
95 .split("")
96 .filter((char) => patternRegex.test(char))
97 .join("")
98 .slice(0, maxLength)
99
100 if (!isControlled) {
101 setInternalValue(filtered)
102 }
103 onChange?.(filtered)
104
105 if (filtered.length === maxLength) {
106 onComplete?.(filtered)
107 }
108 },
109 [isControlled, maxLength, onChange, onComplete, patternRegex]
110 )
111
112 const handleInputChange = useCallback(
113 (e: React.ChangeEvent<HTMLInputElement>) => {
114 handleChange(e.target.value)
115 },
116 [handleChange]
117 )
118
119 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.metaKey
129 ) {
130 return
131 }
132
133 if (!patternRegex.test(e.key)) {
134 e.preventDefault()
135 }
136 },
137 [patternRegex]
138 )
139
140 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 )
148
149 const handleFocus = useCallback(() => {
150 setIsFocused(true)
151 }, [])
152
153 const handleBlur = useCallback(() => {
154 setIsFocused(false)
155 }, [])
156
157 return (
158 <InputOTPContext.Provider
159 value={{
160 value,
161 maxLength,
162 disabled,
163 activeIndex,
164 isFocused,
165 focusInput,
166 placeholder,
167 }}
168 >
169 <div
170 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 <input
177 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"
201
202// Group
203type InputOTPGroupProps = {
204 children: ReactNode
205 className?: string
206} & HTMLAttributes<HTMLDivElement>
207
208function InputOTPGroup({ children, className, ...props }: InputOTPGroupProps) {
209 return (
210 <div className={cn("flex items-center", className)} {...props}>
211 {children}
212 </div>
213 )
214}
215
216// Slot
217type InputOTPSlotProps = {
218 index: number
219 className?: string
220} & HTMLAttributes<HTMLDivElement>
221
222function InputOTPSlot({ index, className, ...props }: InputOTPSlotProps) {
223 const { value, activeIndex, isFocused, disabled, focusInput, placeholder } = useInputOTP()
224
225 const char = value[index] ?? ""
226 const isActive = isFocused && index === activeIndex
227 const isFilled = char !== ""
228
229 return (
230 <div
231 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 className
237 )}
238 data-active={isActive ? "" : undefined}
239 data-filled={isFilled ? "" : undefined}
240 onClick={focusInput}
241 {...props}
242 >
243 {isFilled ? (
244 char
245 ) : 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}
256
257// Separator
258type InputOTPSeparatorProps = {
259 children?: ReactNode
260 className?: string
261} & HTMLAttributes<HTMLDivElement>
262
263function InputOTPSeparator({ children, className, ...props }: InputOTPSeparatorProps) {
264 return (
265 <div
266 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}
278
279export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
280

Usages

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>