Input OTP
One-time-code field drawn as separate slots over a single input.
Installation
Terminal
npx magic-native-ui@latest add input-otpUsage
The example running in the preview above. Imports use the default aliases from components.json.
input-otp-demo.tsx
import * as React from 'react'
import { View } from 'react-native'
import { InputOTP } from '@/components/ui/input-otp'
import { Label } from '@/components/ui/label'
import { Text } from '@/components/ui/text'
export function InputOTPDemo() {
const [code, setCode] = React.useState('')
return (
<View className="w-full gap-5">
<View className="gap-1.5">
<Label>Verification code</Label>
<InputOTP value={code} onChangeText={setCode} />
<Text className="text-xs text-muted-foreground">
{code.length === 6 ? 'Ready to verify.' : 'Enter the 6-digit code we sent you.'}
</Text>
</View>
<View className="gap-1.5">
<Label>PIN</Label>
<InputOTP masked length={4} defaultValue="12" />
</View>
</View>
)
}Component source
What the CLI copies into your project. Once it is there, the code is yours to edit.
registry/ui/input-otp.tsx
import * as React from 'react'
import { Pressable, TextInput, View } from 'react-native'
import { Text } from '@/registry/ui/text'
import { cn } from '@/registry/lib/utils'
type InputOTPProps = Omit<
React.ComponentProps<typeof TextInput>,
'value' | 'onChangeText' | 'maxLength'
> & {
value?: string
defaultValue?: string
onChangeText?: (value: string) => void
/** Called once the last slot is filled. */
onComplete?: (value: string) => void
length?: number
invalid?: boolean
/** Renders dots instead of digits, for PINs. */
masked?: boolean
className?: string
}
/**
* The slots are plain views — a real `TextInput` sits transparently on top of
* them, which keeps one cursor, one autofill target and one keyboard for the
* whole code. Splitting it into one field per digit breaks all three.
*/
function InputOTP({
className,
value: valueProp,
defaultValue = '',
onChangeText,
onComplete,
length = 6,
invalid,
masked,
editable,
readOnly,
onFocus,
onBlur,
...props
}: InputOTPProps) {
const inputRef = React.useRef<TextInput>(null)
const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
const [focused, setFocused] = React.useState(false)
const value = valueProp ?? uncontrolled
const off = editable === false || readOnly === true
function handleChangeText(next: string) {
const digits = next.replace(/\D/g, '').slice(0, length)
if (digits === value) return
setUncontrolled(digits)
onChangeText?.(digits)
if (digits.length === length) {
onComplete?.(digits)
}
}
return (
<Pressable
className={cn('w-full flex-row gap-2', off && 'opacity-50', className)}
disabled={off}
onPress={() => inputRef.current?.focus()}
>
{Array.from({ length }, (_, index) => {
const char = value[index]
// With every slot filled there is no next one, so the last stays lit.
const active = focused && index === Math.min(value.length, length - 1)
return (
<View
key={index}
className={cn(
'h-12 flex-1 items-center justify-center rounded-md border border-input bg-background',
active && 'border-2 border-ring',
invalid && 'border-destructive'
)}
>
<Text className="text-xl font-semibold">
{char ? (masked ? '\u2022' : char) : ''}
</Text>
</View>
)
})}
<TextInput
ref={inputRef}
value={value}
onChangeText={handleChangeText}
maxLength={length}
editable={editable}
readOnly={readOnly}
caretHidden
keyboardType="number-pad"
inputMode="numeric"
// Lets iOS and Android offer the SMS code straight from the keyboard.
textContentType="oneTimeCode"
autoComplete="one-time-code"
aria-invalid={invalid}
onFocus={(event) => {
setFocused(true)
onFocus?.(event)
}}
onBlur={(event) => {
setFocused(false)
onBlur?.(event)
}}
className="absolute left-0 top-0 h-full w-full text-transparent opacity-0"
{...props}
/>
</Pressable>
)
}
export { InputOTP }
export type { InputOTPProps }