Forms

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.

Controlled or not#

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>
'use server'
 
export async function verifyCode(formData: FormData) {
  const code = formData.get('code') // "123456"
  // …
}

Passing value makes it controlled. Note that onChange hands you a string, not an event, so a useState setter drops straight in:

value: ""

Submitting on completion#

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.

Type six characters — no submit button needed.

<OTPInput
  maxLength={6}
  onComplete={() => formRef.current?.requestSubmit()}
/>

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()}
/>

While the request is in flight#

Disable the field so a second onComplete can't fire mid-request, and so the user isn't editing a code that's already being checked:

const { pending } = useFormStatus()
 
<OTPInput
  maxLength={6}
  disabled={pending}
  containerClassName="group flex has-[:disabled]:opacity-50"
/>

Errors#

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
  }
}

Disabled and read-only#

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

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.

react-hook-form#

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>

Labelling#

One input means one <label>. See Accessibility — it is a short page and it matters more than most of this one.