InputOTP
The one-time code — Okami's login step. Squircle boxes in two even groups (login's eight, as four and four), over one real input, so paste, one-time-code autofill and password managers just work. Built on the vendored input-otp.
Playground
import { InputOTP } from "@/components/shared/input-otp";
const [code, setCode] = useState("");
<InputOTP
value={code}
onChange={setCode}
aria-label="Sign-in code"
/>The login step
The code from the email, and a button to confirm it. onComplete submits the form as soon as the last digit lands; the button stays for anyone who pastes and waits. Login asks for eight digits in sm boxes, so the row fits a phone. In this demo the code is 48291357 — anything else shows the error.
import { InputOTP } from "@/components/shared/input-otp";
import { Button } from "@/components/shared/button";
import { FieldError } from "@/components/shared/field";
<form ref={form} action={verifyAction}>
<InputOTP
length={8}
size="sm"
name="token"
value={code}
onChange={setCode}
onComplete={() => form.current?.requestSubmit()}
aria-label="Sign-in code"
aria-invalid={wrong || undefined}
aria-describedby={wrong ? "code-error" : undefined}
/>
<FieldError id="code-error">
{wrong && "Wrong or expired code. Try again."}
</FieldError>
<Button type="submit" size="lg" loading={verifying} className="w-full">
{verifying ? "Verifying…" : "Sign in"}
</Button>
</form>Sizes
Boxes are square at the Input heights: sm 32px, md 36px by default, lg 40px. That is their most: in a narrower column they shrink to fit, still square, so the row never runs past it — on a phone, six lg boxes come out a little smaller. Login uses sm: its eight boxes fit a phone's width as they are.
import { InputOTP } from "@/components/shared/input-otp";
<InputOTP size="sm" aria-label="Sign-in code" />
<InputOTP aria-label="Sign-in code" />
<InputOTP size="lg" aria-label="Sign-in code" />Length
length sets how many characters, split into two even groups. Match it to the auth provider's code length — Supabase sends 6 by default and can be set to 8. Eight boxes are wide: on a phone, keep them at sm.
import { InputOTP } from "@/components/shared/input-otp";
<InputOTP length={8} size="sm" aria-label="Sign-in code" />Invalid & disabled
aria-invalid gives every box a destructive edge — pair it with a FieldError. Keep the code in place so it can be corrected; clear the error as soon as it changes. Disabled dims the whole row, e.g. while a new code is on its way.
import { InputOTP } from "@/components/shared/input-otp";
import { FieldError } from "@/components/shared/field";
<InputOTP value="482931" aria-label="Sign-in code" aria-invalid aria-describedby="code-error" />
<FieldError id="code-error">That code doesn't match. Check the latest email.</FieldError>
<InputOTP aria-label="Sign-in code" disabled />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| length | number | 6 | How many characters. Split into two even groups with a dash between. |
| size | "sm" | "md" | "lg" | "md" | Box size: 32, 36 or 40px, like Input. Less in a narrower column: the boxes shrink to fit it. |
| value | string | — | The code so far. Omit for an uncontrolled field. |
| onChange | (value: string) => void | — | Called with the whole code on every change. |
| onComplete | (value: string) => void | — | Called once every box is filled — submit the form here. |
| pattern | string | REGEXP_ONLY_DIGITS | A regex source each character must match. Digits only by default. |
| name | string | — | Posts the code with its form — no hidden input needed. |
| aria-label | string | — | Names the code for assistive tech. Required unless a label points at its id. |
| aria-invalid | boolean | false | Marks the code wrong: every box gets a destructive edge. |
| disabled | boolean | false | Disables the input and dims the row. |
| className | string | — | Layout for the row of boxes — margins, alignment. |
Everything else passes through to the one real <input>: id, autoFocus, onBlur, ref, … It already sets autoComplete="one-time-code" and inputMode="numeric".
Guidelines
- Say where the code went (“We sent a code to jana@studio-lumen.sk”) right above the boxes, and name the field with
aria-label— the boxes alone don't say what they are. - Submit on
onComplete, but keep a Sign in button: some people paste and look before they commit, and a button is where a loading state belongs. - On a wrong code, keep the digits, mark the field invalid and say what to do next. Offer a new code nearby; don't clear the boxes behind the person's back.
- Keep
lengthin step with the auth provider. A mismatch lets people finish typing a code the server will never accept. - Use it for short codes only. Anything a person chooses — a PIN they set, a promo code — is an Input.