Digit input
A one-time code field. Type in it: the pills seed a state, but the component underneath is the real thing.
<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.
| Prop | Type | Default | Notes |
|---|---|---|---|
length | number | 4 | Cell count. Also sets maxLength. |
onComplete | (value: string) => void | — | Fires when the last cell fills. |
mask | boolean | false | Dots instead of digits, for PINs. |
Shared
| Prop | Type | Default | Notes |
|---|---|---|---|
value | string | — | Controlled value. |
defaultValue | string | — | Initial value when uncontrolled. |
onChange | (value: string) => void | — | Receives the value, never the event. |
label | ReactNode | — | Rendered as a real <label> wired by htmlFor. |
hint | ReactNode | — | Helper text under the field. |
error | ReactNode | — | Error text. Implies invalid unless invalid says otherwise. |
invalid | boolean | — | Forces the error styling on or off. |
errorKey | string | number | — | Change it to replay the error animation. |
disabled | boolean | false | Greys the field out. |
readOnly | boolean | false | Reads as filled, stays focusable. |
size | 'sm' | 'md' | 'lg' | 'md' | Size scale, where the component defines one. |
classNames | Record<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.
| Attribute | Sits on | Meaning |
|---|---|---|
data-active | the focused cell | The cell the caret is in. |
data-filled | root, and each filled cell | Has a value. |
data-invalid | root and cells | Error state. |
data-disabled | root and cells | Disabled. |
data-focused | root | The field has focus. |
<PinInput
classNames={{
cell: 'data-[active]:ring-4 data-[filled]:bg-neutral-50',
}}
/>