Digit input

A one-time code field. Type in it: the pills seed a state, but the component underneath is the real thing.

Enter the code we sent you.
<PinInput
  length={4}
  label="Verification code"
  hint="Enter the code we sent you."
  onComplete={verify}
/>

One input, not one per cell

The cells are presentation. Underneath sits a single real <input> spanning the whole group. That is what makes pasting a code, autocomplete="one-time-code" and the iOS and Android SMS keyboard suggestion work. All three break the moment you render one input per cell, which is how most hand-rolled versions are built.

Props

Three of its own, on top of the shared contract.

PropTypeDefaultNotes
lengthnumber4Cell count. Also sets maxLength.
onComplete(value: string) => voidFires when the last cell fills.
maskbooleanfalseDots instead of digits, for PINs.

Shared

PropTypeDefaultNotes
valuestringControlled value.
defaultValuestringInitial value when uncontrolled.
onChange(value: string) => voidReceives the value, never the event.
labelReactNodeRendered as a real <label> wired by htmlFor.
hintReactNodeHelper text under the field.
errorReactNodeError text. Implies invalid unless invalid says otherwise.
invalidbooleanForces the error styling on or off.
errorKeystring | numberChange it to replay the error animation.
disabledbooleanfalseGreys the field out.
readOnlybooleanfalseReads as filled, stays focusable.
size'sm' | 'md' | 'lg''md'Size scale, where the component defines one.
classNamesRecord<Part, string>Per-part class names.

Errors

An error implies invalid, and the field shakes once when it appears. A second failed submit with the same message would otherwise do nothing visible, so change errorKey to replay it.

verify-form.tsx
const [attempt, setAttempt] = useState(0)

<PinInput
  error={wrong ? 'That code is incorrect. Try again.' : undefined}
  errorKey={attempt}
  onComplete={async (code) => {
    const ok = await verify(code)
    if (!ok) setAttempt((n) => n + 1)
  }}
/>

State attributes

Every state is on the DOM, so it can be styled from outside.

AttributeSits onMeaning
data-activethe focused cellThe cell the caret is in.
data-filledroot, and each filled cellHas a value.
data-invalidroot and cellsError state.
data-disabledroot and cellsDisabled.
data-focusedrootThe field has focus.
<PinInput
  classNames={{
    cell: 'data-[active]:ring-4 data-[filled]:bg-neutral-50',
  }}
/>