OTP input slots with keyboard-aware focus behavior
1
2
3
4
5
6
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
...import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
export default function InputOTPDemoExample() {
return (
<InputOTP defaultValue="123456" maxLength={6}><InputOTPGroup>{Array.from({ length: 6 }, (_, index) => <InputOTPSlot index={index} />)}</InputOTPGroup></InputOTP>
)
}
Installation
npx @fictjs/shadcn@latest add input-otpCopy the Fict shadcn component into your project.
Source
components/ui/input-otp.tsx
src/components/ui/input-otp.tsx
import { createContext, useContext } from 'fict'
import { createSignal } from 'fict/advanced'
import { cn } from '@/lib/cn'
type DivProps = JSX.IntrinsicElements['div']
type MaybeAccessor<T> = T | (() => T)
type InputOTPProps = DivProps & {
value?: MaybeAccessor<string>
defaultValue?: string
onValueChange?: (value: string) => void
maxLength?: number
pattern?: RegExp
disabled?: boolean
required?: boolean
}
type SlotProps = JSX.IntrinsicElements['input'] & {
index: number
total?: number
}
type InputOTPContextValue = {
value: () => string
update: (index: number, character: string) => void
maxLength: number
pattern?: RegExp
disabled: boolean
required: boolean
}
const InputOTPContext = createContext<InputOTPContextValue | null>(null)
function read<T>(value: MaybeAccessor<T> | undefined, fallback: T): T {
if (typeof value === 'function') return (value as () => T)()
return value ?? fallback
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function focusSibling(target: HTMLInputElement, index: number, total: number): void {
const nextIndex = clamp(index, 0, total - 1)
const root = target.closest('[data-slot="input-otp"]')
if (!root) return
const next = root.querySelector('[data-otp-index="' + nextIndex + '"]') as HTMLInputElement | null
next?.focus()
next?.select()
}
export function InputOTP(props: InputOTPProps) {
const internalValue = createSignal((props.defaultValue ?? '').slice(0, props.maxLength ?? 6))
const maxLength = props.maxLength ?? 6
const value = () => read(props.value, internalValue()).slice(0, maxLength)
const update = (index: number, character: string) => {
const nextCharacter = [...character].find(candidate => !props.pattern || props.pattern.test(candidate)) ?? ''
const characters = value().padEnd(maxLength, ' ').split('')
characters[index] = nextCharacter || ' '
const next = characters.join('').trimEnd()
if (props.value === undefined) internalValue(next)
props.onValueChange?.(next)
}
const { class: className, children, value: _value, defaultValue, onValueChange, maxLength: _maxLength, pattern, disabled, required, ...rest } = props
return (
<InputOTPContext.Provider value={{ value, update, maxLength, pattern, disabled: disabled ?? false, required: required ?? false }}>
<div data-slot='input-otp' class={cn('flex items-center gap-2', disabled && 'opacity-50', className)} {...rest}>{children}</div>
</InputOTPContext.Provider>
)
}
export function InputOTPGroup(props: DivProps) {
const { class: className, ...rest } = props
return <div data-slot='input-otp-group' class={cn('flex items-center gap-2', className)} {...rest} />
}
export function InputOTPSlot(props: SlotProps) {
const context = useContext(InputOTPContext)
if (!context) throw new Error('InputOTPSlot must be used inside InputOTP')
const { class: className, index, total = context.maxLength, onInput, onKeyDown, disabled, ...rest } = props
return (
<input
inputMode={context.pattern?.test('a') ? 'text' : 'numeric'}
maxLength={1}
autoComplete='one-time-code'
data-otp-index={index}
data-slot='input-otp-slot'
value={() => context.value()[index] ?? ''}
disabled={context.disabled || disabled}
required={context.required}
class={cn('h-10 w-10 rounded-md border border-input bg-background text-center text-sm font-semibold shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', className)}
onInput={(event: Event) => {
onInput?.(event)
const target = event.currentTarget as HTMLInputElement | null
if (!target) return
context.update(index, target.value.slice(-1))
if (target.value.length >= 1) {
focusSibling(target, index + 1, total)
}
}}
onKeyDown={(event: KeyboardEvent) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
const target = event.currentTarget as HTMLInputElement | null
if (!target) return
if (event.key === 'Backspace' && target.value.length === 0) {
context.update(index, '')
focusSibling(target, index - 1, total)
}
}}
{...rest}
/>
)
}
export function InputOTPSeparator(props: DivProps) {
const { class: className, children, ...rest } = props
return (
<div data-slot='input-otp-separator' class={cn('text-muted-foreground', className)} {...rest}>
{children ?? '-'}
</div>
)
}
Usage
Examples
Pattern
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
...import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
export default function InputOTPPatternExample() {
return (
<label class="grid gap-2">Digits Only<InputOTP maxLength={6} pattern={/^[0-9]$/}><InputOTPGroup>{Array.from({ length: 6 }, (_, index) => <InputOTPSlot index={index} aria-label={`Digit ${index + 1}`} />)}</InputOTPGroup></InputOTP></label>
)
}
Separator
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
...import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
export default function InputOTPSeparatorExample() {
return (
<InputOTP maxLength={6} pattern={/^[0-9]$/}><InputOTPGroup>{[0, 1].map(index => <InputOTPSlot index={index} />)}</InputOTPGroup><InputOTPSeparator /><InputOTPGroup>{[2, 3].map(index => <InputOTPSlot index={index} />)}</InputOTPGroup><InputOTPSeparator /><InputOTPGroup>{[4, 5].map(index => <InputOTPSlot index={index} />)}</InputOTPGroup></InputOTP>
)
}
Disabled
1
2
3
4
5
6
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
...import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
export default function InputOTPDisabledExample() {
return (
<InputOTP disabled defaultValue="123456" maxLength={6}><InputOTPGroup>{[0, 1, 2].map(index => <InputOTPSlot index={index} />)}</InputOTPGroup><InputOTPSeparator /><InputOTPGroup>{[3, 4, 5].map(index => <InputOTPSlot index={index} />)}</InputOTPGroup></InputOTP>
)
}
Controlled
Enter your one-time password.
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
...import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
export default function InputOTPControlledExample() {
let value = $state('')
return (
<div class="grid gap-2"><InputOTP value={() => value} onValueChange={next => { value = next }} maxLength={6}><InputOTPGroup>{Array.from({ length: 6 }, (_, index) => <InputOTPSlot index={index} />)}</InputOTPGroup></InputOTP><p>{value ? `You entered: ${value}` : 'Enter your one-time password.'}</p></div>
)
}
Invalid
0
0
0
0
0
0
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
...import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
export default function InputOTPInvalidExample() {
return (
<InputOTP defaultValue="000000" maxLength={6} aria-invalid="true"><InputOTPGroup>{[0, 1].map(index => <InputOTPSlot index={index} aria-invalid="true" />)}</InputOTPGroup><InputOTPSeparator /><InputOTPGroup>{[2, 3].map(index => <InputOTPSlot index={index} aria-invalid="true" />)}</InputOTPGroup><InputOTPSeparator /><InputOTPGroup>{[4, 5].map(index => <InputOTPSlot index={index} aria-invalid="true" />)}</InputOTPGroup></InputOTP>
)
}
Four Digits
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
...import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
export default function InputOTPFourDigitsExample() {
return (
<InputOTP maxLength={4} pattern={/^[0-9]$/}><InputOTPGroup>{Array.from({ length: 4 }, (_, index) => <InputOTPSlot index={index} />)}</InputOTPGroup></InputOTP>
)
}
Alphanumeric
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
...import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
export default function InputOTPAlphanumericExample() {
return (
<InputOTP maxLength={6} pattern={/^[a-zA-Z0-9]$/}><InputOTPGroup>{[0, 1, 2].map(index => <InputOTPSlot index={index} />)}</InputOTPGroup><InputOTPSeparator /><InputOTPGroup>{[3, 4, 5].map(index => <InputOTPSlot index={index} />)}</InputOTPGroup></InputOTP>
)
}
Form
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
...import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
export default function InputOTPFormExample() {
let code = $state('')
return (
<Card class="w-[336px]"><CardHeader><CardTitle>Verify your login</CardTitle><CardDescription>Enter the verification code we sent to your email address: <strong>m@example.com</strong>.</CardDescription></CardHeader><CardContent><Field><div class="flex items-center justify-between"><FieldLabel>Verification code</FieldLabel><Button type="button" variant="outline" size="xs">Resend Code</Button></div><InputOTP required value={() => code} onValueChange={next => { code = next }} maxLength={6}><InputOTPGroup>{[0, 1, 2].map(index => <InputOTPSlot index={index} class="h-12 w-11" />)}</InputOTPGroup><InputOTPSeparator /><InputOTPGroup>{[3, 4, 5].map(index => <InputOTPSlot index={index} class="h-12 w-11" />)}</InputOTPGroup></InputOTP><FieldDescription><a href="#">I no longer have access to this email address.</a></FieldDescription></Field></CardContent><CardFooter class="grid gap-2"><Button type="submit">Verify</Button><p>Having trouble signing in? <a href="#">Contact support</a></p></CardFooter></Card>
)
}
Rtl
1
2
3
4
5
6
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
...import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp'
const translations = {
ar: { dir: 'rtl', label: 'رمز التحقق' },
he: { dir: 'rtl', label: 'קוד אימות' },
en: { dir: 'ltr', label: 'Verification code' },
} as const
export default function InputOTPRtlExample() {
let language = $state<keyof typeof translations>('ar')
const text = () => translations[language]
return (
<label class="grid gap-2" dir={text().dir}><select value={language} onChange={event => { language = event.currentTarget.value as keyof typeof translations }}><option value="ar">Arabic (العربية)</option><option value="he">Hebrew (עברית)</option><option value="en">English</option></select>{text().label}<InputOTP dir={text().dir} defaultValue="123456" maxLength={6}><InputOTPGroup>{Array.from({ length: 6 }, (_, index) => <InputOTPSlot index={index} />)}</InputOTPGroup></InputOTP></label>
)
}