Controlled and uncontrolled values, auto-submit on completion, react-hook-form, and server actions.
The field is a real <input> with a name and a value, so it behaves like one everywhere: in a plain HTML form, in a server action, in react-hook-form, in whatever you already use. Nothing here is input-otp-specific except onComplete.
Omit value and the component keeps its own state — seeded from defaultValue, readable by the form through name. That is the right default; reach for control only when something else needs to read or write the value mid-flight.
// The component keeps its own value; the form reads it by name.<form action={verifyCode}> <OTPInput name="code" maxLength={6} required /> <button type="submit">Verify</button></form>
Nobody wants to press a button after typing the last digit of a code they just read off a phone. onComplete fires once, on the transition into a full value — from typing, from a paste, or from an SMS autofill that drops all six characters at once.
import * as React from 'react'import { OTPInput } from 'input-otp'import { Slot } from '@/components/ui/otp-slot'export function AutoSubmitDemo() { const formRef = React.useRef<HTMLFormElement>(null) const [status, setStatus] = React.useState<'idle' | 'verifying' | 'done'>( 'idle', ) return ( <form ref={formRef} onSubmit={async e => { e.preventDefault() setStatus('verifying') await new Promise(resolve => setTimeout(resolve, 900)) setStatus('done') }} className="flex flex-col items-center gap-5" > <OTPInput name="code" maxLength={6} autoFocus={false} disabled={status !== 'idle'} // Fires exactly once, on the transition into a full value — including // when the value arrives all at once from a paste or an SMS autofill. onComplete={() => formRef.current?.requestSubmit()} containerClassName="group flex items-center has-[:disabled]:opacity-50" render={({ slots }) => ( <div className="flex"> {slots.map((slot, idx) => ( <Slot key={idx} {...slot} /> ))} </div> )} /> <p className="h-5 text-sm text-muted-foreground" aria-live="polite"> {status === 'idle' && 'Type six characters — no submit button needed.'} {status === 'verifying' && 'Verifying…'} {status === 'done' && ( <span className="text-emerald-400">Submitted.</span> )} </p> {status === 'done' && ( <button type="button" onClick={() => setStatus('idle')} className="rounded-md border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground" > Reset </button> )} </form> )}
If auto-submitting feels too eager — a slow request, a destructive action, a code the user might want to double-check — move focus instead and let them confirm:
<OTPInput maxLength={6} // Hand the user the button instead of submitting for them. onComplete={() => submitRef.current?.focus()}/>
A wrong code is not a validation error in the HTML sense — the input is perfectly well-formed, the server just disagrees with it. So you own the state, and you own the announcement:
aria-invalid on the field, and aria-describedby pointing at the message.
role="alert" on the message so it is read when it appears.
Clear the error in onChange. An error that persists while the user is visibly fixing it reads as broken.
Keep the shake small, and behind motion-safe:.
If your flow starts over on failure, clear the value and take focus back to the first slot — ref points at the real input, so this is ordinary DOM:
const [value, setValue] = React.useState('')const inputRef = React.useRef<HTMLInputElement>(null)async function check(code: string) { if (await isWrong(code)) { setError('That code is incorrect.') setValue('') // clear it inputRef.current?.focus() // and put them back at slot 0 }}
Both are native, and both are readable from CSS without a prop reaching your slots — has-[:disabled] and has-[:read-only] on the container.
0
4
2
3
1
4
1
5
9
import * as React from 'react'import { OTPInput } from 'input-otp'import { Slot } from '@/components/ui/otp-slot'export function DisabledDemo() { // Held in state only so the demo can show a pre-filled field. const [partial, setPartial] = React.useState('042') const [complete, setComplete] = React.useState('314159') return ( <div className="flex flex-col items-center gap-6"> {/* `has-[:disabled]` reads the state off the real input, so the whole field dims without any extra prop threading. */} <OTPInput disabled maxLength={6} value={partial} onChange={setPartial} containerClassName="group flex items-center has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-40" render={({ slots }) => ( <div className="flex"> {slots.map((slot, idx) => ( <Slot key={idx} {...slot} /> ))} </div> )} /> <OTPInput readOnly maxLength={6} value={complete} onChange={setComplete} containerClassName="group flex items-center has-[:read-only]:cursor-default" render={({ slots }) => ( <div className="flex"> {slots.map((slot, idx) => ( <Slot key={idx} {...slot} /> ))} </div> )} /> </div> )}
Use readOnly when a code should be visible but not editable (showing a recovery code back to the user, for instance) — it stays focusable and selectable, so it can still be copied.
Controller is the path of least resistance — its field hands you value and an onChange that accepts exactly the string input-otp emits, so the spread type-checks as-is:
import { Controller, useForm } from 'react-hook-form'const { control, handleSubmit } = useForm({ defaultValues: { code: '' } })<Controller name="code" control={control} rules={{ minLength: 6 }} render={({ field }) => ( <OTPInput // onChange gives you a string, which is exactly what field.onChange wants {...field} maxLength={6} // handleSubmit(onValid) expects a form event, not the code — wrap it onComplete={() => handleSubmit(onValid)()} /> )}/>
register reaches the real input too (ref is forwarded), but its TypeScript types say onChange takes an event while input-otp calls it with a string. react-hook-form unwraps plain values at runtime, so only the compiler objects — spread register as-is and strict TypeScript rejects the onChange collision. A one-line adapter satisfies it:
import { useForm } from 'react-hook-form'const { register, handleSubmit } = useForm<{ code: string }>()const field = register('code', { minLength: 6, required: true })<form onSubmit={handleSubmit(onValid)}> <OTPInput {...field} maxLength={6} // register() types onChange for events, but input-otp hands it a // string — wrap the string in the event shape react-hook-form reads onChange={value => field.onChange({ target: { name: 'code', value } })} /></form>