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';
<OtpInput length={6} groups={3} value={code} onValueChange={setCode} />

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.

1
2
3
4
Code verified
9
9
9
9
<OtpInput length={4} value="1234" verified />
<OtpInput length={4} value="9999" error="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.

<OtpInput length={5} allow="alphanumeric" mask defaultValue="A7K" />

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

PropType
classNamestringAdded to the component’s own classes, so yours wins
styleCSSPropertiesMerged 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.

PropTypeDefault
lengthnumber6How many cells
valuestringControlled
defaultValuestring''
onValueChange(value: string) => void
onComplete(value: string) => voidFired once the last cell fills
allow'digits' | 'letters' | 'alphanumeric''digits'Everything else is dropped
uppercasebooleanon unless digits
maskbooleanfalseDots instead of characters
groupsnumberA separator every N cells
errorReactNodeTurns the cells, shakes once, says why
verifiedbooleanfalseAccepted: turns, pulses, goes read-only
verifiedLabelstring'Code verified'Announced when it turns
sizenumber48Cell height; width, radius and type follow it
radiusnumbersize × 0.25
gapnumber8
theme'dark' | 'light''dark'
disabledbooleanfalse
labelstring'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

    verified and error are told to it. It draws three states; deciding which one you are in is the server's job.