Input
OTP input
A one-time code, in cells. There is one real input underneath and the cells are drawn over it — which is why paste, autofill and the phone keyboard all behave.
Usage
import { OtpInput } from '@carabine/ui/otp-input';Six inputs is six things to keep in sync, and every one of them is a place for a caret to go missing. There is one.
One input, many cells
The cells are divs. Underneath them sits a single transparent <input> with autocomplete="one-time-code", and everything a code field is expected to do — paste six digits at once, take the code the OS offers from an SMS, show a numeric keyboard, be found by a password manager — is that input doing its job rather than six of them being coordinated.
The caret is drawn, not native, and its blink lives in otp.css: a steps() keyframe is not something a utility can express.
Examples
Verified and wrong
Three states, and the component draws all three without deciding which one you are in. verified turns the cells, pulses once and goes read-only; error turns them the other way, shakes once, and says why.
That code has expired.
Letters, and masking
allow filters what may be typed — everything else is dropped rather than rejected with a message. uppercase is on by default for anything that is not digits, and mask draws dots.
Customization
<OtpInput
length={8}
groups={4} // a separator every N cells
size={44} // cell height; the width, radius and type follow it
radius={12}
gap={6}
onComplete={verify} // fires once the last cell fills
theme="light"
/>Styling reference
Tailwind utilities plus otp.css for the caret's blink and the error shake. Each cell is a recess — the text field's material at another size — so a code field and a text field on the same form read as the same thing.
API reference
| Prop | Type | |
|---|---|---|
| className | string | Added to the component’s own classes, so yours wins |
| style | CSSProperties | Merged after the component’s own inline styles, so yours wins |
Colours are data and dimensions are props, and neither covers a margin, a font, or a class from your own system. That is what these are for. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.
| Prop | Type | Default | |
|---|---|---|---|
| length | number | 6 | How many cells |
| value | string | — | Controlled |
| defaultValue | string | '' | |
| onValueChange | (value: string) => void | — | |
| onComplete | (value: string) => void | — | Fired once the last cell fills |
| allow | 'digits' | 'letters' | 'alphanumeric' | 'digits' | Everything else is dropped |
| uppercase | boolean | on unless digits | |
| mask | boolean | false | Dots instead of characters |
| groups | number | — | A separator every N cells |
| error | ReactNode | — | Turns the cells, shakes once, says why |
| verified | boolean | false | Accepted: turns, pulses, goes read-only |
| verifiedLabel | string | 'Code verified' | Announced when it turns |
| size | number | 48 | Cell height; width, radius and type follow it |
| radius | number | size × 0.25 | |
| gap | number | 8 | |
| theme | 'dark' | 'light' | 'dark' | |
| disabled | boolean | false | |
| label | string | 'Verification code' | Accessible name |
What it refuses
No countdown, no resend
Both belong to whatever sent the code, which knows when it expires and how to send another. A field that owned a timer would have to be told when the timer started, which is the same as not owning it.
No verifying of its own
verifiedanderrorare told to it. It draws three states; deciding which one you are in is the server's job.