Pro

Code input

One-time code boxes that fill from a paste or platform autofill and step back on Backspace. The active box shows a dark edge and a caret, invalid shows in the edges; an optional status folds the boxes into a centered, edgeless Verifying pill that turns to Verified, or opens back up on a wrong code.

Confirm your seat

Acme sent a 6-digit code to dana.whitfield@example.com to hold your place at Saturday’s supper. 123456 works; any other code doesn’t.

The code expires in 10 minutes.

Replay the flow

Keep digitsClear digits

Other lengths, grouped, disabled

The 4 digits on the keypad by the side gate.

On the box by the front door.

From your authenticator app.

Letters and digits, in two groups of 4.

Installation

With UI Polish connected, ask your agent:

Add the UI Polish code input to this project.

Or with the shadcn CLI:

npx shadcn@latest add https://uipolish.com/r/code-input.json
Brings inText, Text morph

A one-time code as a row of boxes, one per character: digits by default, or letters and digits with characters="alphanumeric". Typing moves along, Backspace steps back, a paste or the platform's autofill fills every box at once, and the value never has holes in it. Digits are 24px semibold with tabular figures, so every box holds the same weight of ink; each fades in and settles 2px as it lands.

Give it a status and the check gets a shape of its own: while the code is being verified the boxes fold toward the middle into one pill, "Verifying", with a breathing dot; a good code draws a check in and the words morph to "Verified", in the same ink and the same width; a wrong one opens the boxes back up with the digits still in them.

TSX
<CodeInput  label="Verification code"  name="code"  hint="The code expires in 10 minutes."  value={code}  onValueChange={(next) => {    setCode(next);    if (status === "error" && next) setStatus("idle");  }}  status={status}  onComplete={async (value) => {    setStatus("verifying");    setStatus((await verify(value)) ? "success" : "error");  }}/>

When to use it

  • Verification codes sent by email or text, two-factor codes, short PINs.

When not to

  • Anything longer than about 8 characters, or with a variable length: use a text field.
  • Passwords. A code input shows every digit.

Props

PropWhat it does
labelVisible label for the whole code. Required.
lengthNumber of boxes, default 6. Every box is the same width and the same gap apart at any length.
groupOptional grouping the way the code is read aloud: 3 gives 123 456, [3, 4] gives 123 4567. Each group after the first opens with a small dot in a wider gap. Leave it out for one even row.
value, onValueChangeControlled mode. defaultValue for uncontrolled.
onCompleteCalled when a change leaves every box filled: the last digit typed, a paste, or one digit fixed in a full code. Verify here.
characters"digits" (default) or "alphanumeric" (shown upper case).
hintGuidance under the boxes.
errorWhat went wrong and what to do. Turns every edge to danger.
status"idle", "verifying", "success" or "error", set by your app as the check runs. Leave it out and the boxes never fold.
labels{ verifying, success, error }: the pill's words (default "Verifying", "Verified") and the message for status="error" when there's no error prop. Keep pill words to one or two: the pill doesn't wrap.
retryAfter status="error": "select" (default) keeps the digits, all selected, so the next digit typed starts over while a click on one box fixes just that one; "clear" empties the boxes. Focus goes to the first box either way.
nameSubmits the code as one hidden form value.
disabled, autoFocusAs on an input.

Behavior

InputResult
Type a digitFills the box and moves to the next.
Type the digit already thereMoves on.
Click or tap a box past the endFocus goes to the first empty box.
Focus a filled boxIts digit is selected, so typing replaces it.
Backspace on a filled boxClears it; later digits close up. Focus stays.
Backspace on an empty boxClears the one before and steps back.
DeleteClears the box and closes the gap.
← →Move between boxes (mirrored in right to left), never past the first empty one.
Home / EndFirst box / last reachable box.
PasteA whole code replaces the value; part of one continues from the box pasted into. Non-digits (spaces, dashes) are dropped.
Platform autofillThe first box has autocomplete="one-time-code"; a code dropped into it fills every box.
Typing while verifying or successNothing changes: the boxes are read-only, so the code can't change under the check.
Typing after a failed code (retry="select")The first digit replaces the whole code and moves on; Backspace clears it all; an arrow key or a click keeps the digits and edits one.

Always contiguous. The value is a string with no holes, so there's never a "1_3456" to submit. That's why focus can't land past the first empty box and why deleting from the middle closes the gap.

States

StateWhat you seeHow
Empty or filled boxHairline edge in --ui-border-strong, the same edge as a text field
HoverEdge darkens to --ui-field-focus@media (hover: hover)
Active boxEdge in --ui-field-focus, instant. Empty: a drawn 2px caret blinks in the middle (steady under reduced motion). Filled: the digit is selected:focus
InvalidEvery edge eases to danger; the hint fades out and the error fades in, in the same reserved lineerror → aria-invalid
Disabled50% opacity, default cursordisabled

Only the active box has a dark edge, so where the next digit goes is never in doubt. Nothing moves between these states: edges change color, messages fade. Digits appear the instant they're typed, as in any field.

Status

The check is the one place the component changes shape, because the shape says what's happening: you can't type into a pill.

statusWhat you seeHow
idleThe boxes.data-phase="cells"
verifyingBoxes locked at once. If the check is still running after 300ms, they drift toward the middle and fade into one pill: a quiet --ui-fill with no edge, --ui-control-md (40px) tall, centered on the row both ways, which tightens from both sides to fit a ring with a breathing dot and "Verifying" in muted ink. Once shown it stays 400ms at least, so a fast answer never flickers.data-phase="verifying", aria-busy
successNo color comes in, only ink: the dot gives way to a check drawing in, the ring fills in, and "Verifying" morphs to "Verified" (shared letters stay put) as the ink goes from muted to foreground. The pill eases to the shorter word's width from both sides, so it stays centered; no tint, no light band. A fast answer goes straight from the boxes to this.data-phase="success"
errorThe pill widens back to the row from its middle and dissolves as the boxes come out of it with their digits and danger edges, then the row nudges sideways once. The error fades in below with a 2px drop.data-phase="cells", aria-invalid

The pill is centered on the digit row's own box, horizontally and vertically, at every frame of the fold, in the same space, so nothing below or beside it moves: the boxes stay in the layout, invisible, while it shows, and the field keeps its height and width. The padding is even on both sides, so the glyph and words sit on that center too. In a left-aligned form the pill centers under the digits, not under the label or the form: the row is as wide as its boxes, and the pill takes its middle.

Motion

WhatDurationEasing
Edge on hover--ui-dur-instant (120ms)--ui-ease-color
Caret blink--ui-dur-pulse on, --ui-dur-pulse off, while the active box is emptysteps
Edge on focus0msn/a
Edge to danger and back--ui-dur-fast (160ms)--ui-ease-color
Hint ↔ error crossfade150ms out, 200ms in after 75ms--ui-ease-color, --ui-ease-enter
Error arriving2px drop and a 2px blur clearing, --ui-dur-base--ui-ease-enter
Boxes into the pillfade and 2px blur --ui-dur-fast; drift toward the middle --ui-dur-glide--ui-ease-exit; --ui-ease-glide
Pill width, either way, from both sides--ui-dur-glide (320ms)--ui-ease-glide, no overshoot
Pill label and glyph in / out--ui-dur-base after --ui-dur-instant / --ui-dur-instant--ui-ease-enter / --ui-ease-exit
Breathing dot--ui-dur-pulse each way, while verifying--ui-ease-pulse, alternate
"Verifying" → "Verified"Text morph; the pill follows its width from both sidesas Text morph
Label ink, muted → foreground--ui-dur-base--ui-ease-color
Check drawing in--ui-dur-base after 60ms--ui-ease-draw
Pill dissolving as it opens--ui-dur-base after --ui-dur-instant--ui-ease-color
Boxes out of the pillfade and blur --ui-dur-base after 60ms; drift back --ui-dur-glide--ui-ease-enter; --ui-ease-glide
Nudge on error4px, 3px, 1px over --ui-dur-move, after the boxes are back--ui-ease-pulse

Reduced motion keeps every state and drops the movement: the boxes and the pill crossfade in place, the pill takes its width while unseen, the label swaps without morphing, the caret holds steady, and there's no drift, blur, breathing or nudge. The check still appears.

Accessibility

  • The boxes are a role="group" labelled by the visible label and described by the hint or error. Each box is named "Digit 1 of 6" and so on.
  • inputmode="numeric" and pattern="[0-9]*" bring up the number pad.
  • Digits are 24px, over the 16px below which iOS zooms into a field.
  • The message line is aria-live="polite", so an error is read out without moving focus. Clear error as soon as the user edits.
  • Clicking the label focuses the first empty box.
  • With status: the group is aria-busy while verifying. A polite status region says "Verifying" and then "Verified" as the pill shows them, and the error is read from the message line. While the boxes are a pill, focus waits on the group (which says its label and status); when they come back it returns to the first box, but only if it was in the code input before, so a person who tabbed away isn't pulled back.

Before / after

Before: six separate inputs with maxLength={1}. A paste fills one box, autofill never fires, and Backspace on an empty box does nothing.

After:

TSX
<CodeInput label="Verification code" onComplete={verify} error={error} />

Layout

The row is as wide as its boxes (44 × 48px each, --ui-control-lg tall), and nothing reflows while digits arrive. In a parent narrower than that, every box shrinks by the same amount down to --code-box-min (24px), so it fits its container, not the viewport, at any length. Gaps stay equal at every length (--code-gap, 8px; --code-group-gap, 24px between groups, with the dot in its middle). The message under the boxes always holds one line, and hint and error trade places in it. If an error can arrive on its own (a server check after the last digit) and run past one line, set --code-message-lines: 2 on the component so the slot is already that tall. The same goes for labels.error.

The boxes never push past their container: the row is as wide as the boxes when there's room, and on a narrow phone each box narrows evenly, down to 24px, instead of the row running off the edge.