Skip to content

PinCode

A verification-code field. It looks like six boxes and it is one input: focus, typing, paste, undo and the SMS autofill are all the browser's, and the boxes are paint over the top.

  • Stable
  • React 19
  • One tab stop
  • SMS autofill
  • Form-library ready
Code
<PinCode
label="Verification code"
helper="We sent a 6-digit code by SMS."
onComplete={(code) => verify(code)}
/>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="42" aria-label="Verification code" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">4</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:4"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:5"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>

Six cells, digits only. onComplete is the only event most screens need: it fires the moment the last cell fills, whether the user typed the code, pasted it, or let the phone fill it in. It re-arms whenever the value drops below length — an edit, a reset(), a setValue() or a new value — so a retry calls it again.

The boxes are not inputs. There is exactly one <input> in the markup, stretched over the row and painted transparent; the cells are <div>s with aria-hidden="true", rendered from the value.

<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" … />
<div class="wayra-pin-code-cells" aria-hidden="true">
<div class="wayra-pin-code-group">
<div class="wayra-pin-code-cell" data-filled="true">…</div>
…
</div>
</div>
</div>

That single decision removes most of what a code field usually needs code for:

The usual problem What happens here
Six tab stops One. Tab lands on the field, not on its first digit.
Arrow keys between boxes There is nowhere to move — the caret is one caret.
Paste into box 1 of 6 Native paste, distributed by the value itself.
Backspace on an empty box Native deletion.
SMS autofill filling one box The platform fills the field, and every cell repaints.
Undo (Ctrl+Z) The browser’s own, because the value never left the input.

The legacy component rendered length inputs and carried a handleKeyDown, a handlePaste and an array of refs to move focus between them. None of that exists any more.

The same four looks Input ships, with the same meanings — a code row belongs to a form, so it has to be able to match the fields around it.

Code
<PinCode length={4} groupBy={2} aria-label="outline" />
<PinCode variant="filled" length={4} groupBy={2} aria-label="filled" />
<PinCode variant="soft" length={4} groupBy={2} aria-label="soft" />
<PinCode variant="ghost" length={4} groupBy={2} aria-label="ghost" />
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="42" aria-label="outline" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">4</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div></div><span class="wayra-pin-code-separator"></span><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="filled" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="42" aria-label="filled" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">4</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div></div><span class="wayra-pin-code-separator"></span><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="soft" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="42" aria-label="soft" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">4</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div></div><span class="wayra-pin-code-separator"></span><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="ghost" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="42" aria-label="ghost" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">4</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div></div><span class="wayra-pin-code-separator"></span><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
Variant What it is
outline default — a boxed cell with its hairline drawn in the role
filled the role becomes the cell and -content the glyph, exactly as on Button
soft neutral base-200 fill — a code row inside a panel
ghost no box at all until the pointer arrives

The hairline is the role at full strength, the same call Input makes for its border: an edge mixed down toward the surface makes all eight color values look alike. Because every role clears 4.5:1 against base-100, the hairline clears the 3:1 WCAG asks of a control boundary for free.

soft keeps that coloured hairline for the same reason Input’s does — base-200 on base-100 measures 1.11:1, so the fill alone does not mark the control. ghost is the deliberate exception, for a row inside a panel the user is already looking at.

Two things filled does that Input has no equivalent for:

  • The caret and the active cell’s inset ring switch to -content. An accent mark on an accent surface is invisible. It is the same reasoning behind Input’s caret-color following its text.
  • The hairline darkens instead of disappearing, so shape="grouped" still shows the seam between two cells of one piece.

A cell is square and exactly as tall as an Input of the same step, so a code row lines up with the rest of a form with no magic number. Every measurement inside — the corner, the glyph, the gap, the masking dot — is a fraction of that one number.

Code
<PinCode size="xs" length={4} aria-label="xs" />
<PinCode size="sm" length={4} aria-label="sm" />
<PinCode size="md" length={4} aria-label="md" />
<PinCode size="lg" length={4} aria-label="lg" />
<PinCode size="xl" length={4} aria-label="xl" />
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated" data-size="xs">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="12" aria-label="xs" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated" data-size="sm">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="12" aria-label="sm" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated" data-size="md">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="12" aria-label="md" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated" data-size="lg">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="12" aria-label="lg" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated" data-size="xl">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="12" aria-label="xl" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
Size Cell Gap (cell × 0.16) Glyph (cell × 0.42)
xs 21.1px 3.4px 8.9px
sm 28.2px 4.5px 11.8px
md 35.2px 5.6px 14.8px
lg 42.2px 6.8px 17.7px
xl 49.3px 7.9px 20.7px
Code
<PinCode length={4} aria-label="separated" />
<PinCode shape="grouped" length={4} aria-label="grouped" />
<PinCode shape="underline" length={4} aria-label="underline" />
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="12" aria-label="separated" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated" data-shape="grouped">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="12" aria-label="grouped" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated" data-shape="underline">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="12" aria-label="underline" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
Shape What it is
separated default — every cell its own flat box
grouped the cells of a group join into one piece and share their seams
underline no box at all, a single rule under each cell — for dense screens

The box is flat, not glass: a hairline ring is the whole of what separates a cell from the page, with no lit edge, no shadow and no blur. It carried a lit top edge and a small contact shadow until 2026-09-24, when the last in-flow surfaces went flat like the overlays before them; a code field sits in the flow.

groupBy breaks the row the way the code is read aloud. The separator is decoration inside the aria-hidden layer — the value is still one string.

Code
<PinCode length={6} groupBy={3} aria-label="Six in two groups" />
<PinCode length={8} groupBy={2} aria-label="Eight in four pairs" />
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="384" aria-label="Six in two groups" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">3</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">8</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-filled="true"><span class="wayra-pin-code-char">4</span><span class="wayra-pin-code-caret"></span></div></div><span class="wayra-pin-code-separator"></span><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:4"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:5"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="3841" aria-label="Eight in four pairs" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">3</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">8</span><span class="wayra-pin-code-caret"></span></div></div><span class="wayra-pin-code-separator"></span><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-filled="true"><span class="wayra-pin-code-char">4</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div></div><span class="wayra-pin-code-separator"></span><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:4" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:5"><span class="wayra-pin-code-caret"></span></div></div><span class="wayra-pin-code-separator"></span><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:6"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:7"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>

The last group is short when the row does not divide evenly — length={7} groupBy={3} gives 3, 3, 1.

A code pasted with its separators still works. 456-789 is filtered down to 456789 on the way in, which is why the field sets no maxLength: that attribute truncates the raw string before any handler can see it, and the paste would lose its last characters to the dashes.

Code
<PinCode mask shape="grouped" length={4} label="PIN" />
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated" data-shape="grouped">
<input class="wayra-pin-code-input" type="password" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="1234" aria-label="PIN" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-dot"></span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-dot"></span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-filled="true"><span class="wayra-pin-code-dot"></span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3" data-filled="true" data-active="true"><span class="wayra-pin-code-dot"></span><span class="wayra-pin-code-caret"></span></div></div></div>
</div>

mask does two things, and the second is the one that matters: the cells paint round dots, and the real input becomes type="password".

allowed filters the value and picks the keyboard.

Code
<PinCode aria-label="Digits" />
<PinCode allowed="alphabetic" length={5} aria-label="Letters" />
<PinCode allowed="alphanumeric" length={6} groupBy={3} aria-label="Both" />
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="38" aria-label="Digits" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">3</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">8</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:4"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:5"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="AB" aria-label="Letters" inputmode="text" autocapitalize="characters" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">A</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">B</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:4"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="A3" aria-label="Both" inputmode="text" autocapitalize="characters" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">A</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">3</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-active="true"><span class="wayra-pin-code-caret"></span></div></div><span class="wayra-pin-code-separator"></span><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:4"><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:5"><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
allowed Keeps Asks the phone for
numeric 0-9 the number pad
alphabetic A-Z automatic capitals
alphanumeric A-Z, 0-9 automatic capitals

transform decides the case afterwards. auto, the default, uppercases whenever letters are allowed and leaves a numeric code alone — a code is read aloud and typed back, and mixed case invites a mismatch that is not the user’s fault. Pass "none" for a case-sensitive code.

Accents are folded before the filter. Typing café into an alphabetic field gives CAFE, not CAF: é is in no allowed set, so filtering first would drop the letter instead of accepting the letter under the accent.

Code
<PinCode length={4} label="Disabled" disabled />
<PinCode length={4} label="Checking" readOnly aria-busy />
<PinCode length={4} label="Wrong" error="That code does not match." />
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="1234" aria-label="Disabled" disabled />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-filled="true"><span class="wayra-pin-code-char">3</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3" data-filled="true" data-active="true"><span class="wayra-pin-code-char">4</span><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="1234" aria-label="Checking" aria-busy="true" readonly />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-filled="true"><span class="wayra-pin-code-char">3</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3" data-filled="true" data-active="true"><span class="wayra-pin-code-char">4</span><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
<div class="wayra-pin-code" data-variant="outline" data-color="primary" data-size="md" data-shape="separated" data-invalid="true">
<input class="wayra-pin-code-input" type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" autocorrect="off" spellcheck="false" value="1234" aria-label="Wrong" aria-invalid="true" />
<div class="wayra-pin-code-cells" aria-hidden="true"><div class="wayra-pin-code-group"><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:0" data-filled="true"><span class="wayra-pin-code-char">1</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:1" data-filled="true"><span class="wayra-pin-code-char">2</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:2" data-filled="true"><span class="wayra-pin-code-char">3</span><span class="wayra-pin-code-caret"></span></div><div class="wayra-pin-code-cell" style="--wayra-pin-code-index:3" data-filled="true" data-active="true"><span class="wayra-pin-code-char">4</span><span class="wayra-pin-code-caret"></span></div></div></div>
</div>
  • disabled fades the row and leaves the tab order, exactly as it does on every other control.
  • aria-busy runs a wave across the cells while a server checks the code. It is a plain attribute, not a prop: the component never knows whether a request is in flight. Pair it with readOnly so the value cannot change under the check, rather than with disabled, which drops the focus and the contrast.
  • error takes the hint slot, announces the message, marks the input aria-invalid and shakes the cells with the wrong-password gesture. The shake replays whenever the attribute starts matching, so clearing the error and setting it again shakes it again.

Every one of those animations moves only opacity, translate or scale — the compositor budget Loading wrote down, watched here by a story of the same name. Under prefers-reduced-motion each duration drops to 0s: the caret stops blinking and rests solid on the active cell rather than disappearing with its keyframes.

name, ref, defaultValue and onChange all come from ComponentProps<"input">, so a native form, TanStack Form or Formik work with no adapter — the value is one string in one real input. value controls it the way it controls an <input>, with the same sanitizing a keystroke gets: the cells paint value, and an edit the consumer does not accept is not painted.

react-hook-form is built in. Inside Form a PinCode registers itself on name alone, the same native register path Input takes:

const form = useForm({ defaultValues: { code: "" } });
<Form form={form} onSubmit={verify}>
<PinCode name="code" label="Verification code" />
<Button type="submit">Verify</Button>
</Form>

The validation message arrives in the hint slot, the field freezes while the form submits, and noRhf turns all of it off for one instance. What the native path buys over a hidden anchor is concrete: shouldFocusError can focus the real control, the field posts correctly in a plain HTML form, and an SMS autofill lands in the store without a single line of glue.

reset() and setValue() repaint the cells. Both write the DOM without firing an event, which the register path alone would never see, so the painted layer also subscribes to the field with useWatch. That is the one place this component needs more wiring than Input does. With no defaultValues a bare reset() leaves the store empty, and the row goes back to defaultValue (or to empty): that is what it paints and what the next submit posts.

Choose name or value. Inside a Form, on a field with a name, the store is the value: a value prop is ignored and the console says so once, so what the row paints is always what the form posts. To control a form field, call form.setValue("code", …); outside a Form, or with noRhf, value controls the row as it would anywhere else.

PinCodeProps extends ComponentProps<"input"> minus the attributes the component owns: size, color, width and height mean something else here, and type, maxLength, placeholder and children are managed or meaningless.

Prop Type Default Description
variant "outline" | "filled" | "soft" | "ghost" "outline" See Variants.
color WayraColor "primary" The cell hairline, the caret and the halo; the cell surface on filled.
size "xs" | "sm" | "md" | "lg" | "xl" "md" Square cells on the field ladder.
shape "separated" | "grouped" | "underline" "separated" See Shape.
length number 6 Cells in the row. Clamps to a minimum of 1.
groupBy number — Splits the row into groups with a separator between them.
mask boolean false Dots in the cells, type="password" on the input.
allowed "numeric" | "alphabetic" | "alphanumeric" "numeric" Filters the value and picks the keyboard.
transform "auto" | "uppercase" | "lowercase" | "none" "auto" Case applied after filtering.
autoComplete string "one-time-code" The token that enables SMS autofill.
value string — Controlled code, sanitized. Ignored inside a Form on a named field.
onComplete (value: string) => void — Fires once an edit fills the row; re-arms below length, reset included.
label ReactNode — Visible label. Wraps the field in the label contract.
labelPlacement "top" | "left" "top" No floating — there is no placeholder to detect emptiness.
helper ReactNode — Persistent hint, wired into aria-describedby.
error ReactNode — Hint slot, role="alert", aria-invalid, and the shake.
noRhf boolean false Opts this instance out of react-hook-form entirely.
disabled boolean false Native.
readOnly boolean false Native. Prefer it over disabled while a code is being checked.
unstyled boolean false Drops the design-system classes, keeps the data attributes.

There is no width and no placeholder. A code field is as wide as its cells, and there is nothing to hint at inside an empty box.

Key Behavior
Tab / Shift+Tab One stop for the whole row, not one per cell.
Typing, Backspace Entirely native — there is one caret and one value.
Ctrl/Cmd+V Native paste, filtered on the way in.
Ctrl/Cmd+Z Native undo, because the value never leaves the input.
  • The cells are aria-hidden. The input carries the value, the name and the announcement; a second reading of the same digits from six <div>s would be noise. This is also why a visible label matters more here than on a normal field — the control has no text of its own to fall back on.
  • Focus is the ring around the active cell, not a ring around the row, which keeps PinCode out of the shared focus-ring rule, one of the few parts that ring themselves. It reads the same ring width and alpha as every other control, so the halo matches.
  • Masking changes the control’s type, so the secret is never announced. See Masking.
  • Windows high contrast. Every cell draws its border from currentColor, so a disabled row takes GrayText and the rest take CanvasText; the active cell adds a Highlight outline, because box-shadow is dropped in that mode. That border is unconditional, which is what keeps variant="ghost" visible there. The masked dots and the group dash are painted backgrounds, which that mode would turn into Canvas, so they keep the cell’s system text colour (GrayText included), and the caret turns Highlight, like the outline it sits in.
  • Reduced motion zeroes the four durations rather than removing the animations, which is what leaves the caret resting solid instead of invisible.

Do

One field, six cells, grouped the way the code is read aloud. Tab reaches it once, and a paste of 429-1… lands whole.

Don't

Four separate inputs. Four tab stops, four things to name, and paste fills only the box you were in.

Do

readOnly while the server checks the code. The row keeps its focus and its full contrast, and the wave says it is working.

Don't

disabled for the same moment. Focus is dropped, the row fades, and the user cannot correct a typo they can already see.

Do

mask for a secret. The cells show dots and the input is a password field, so nothing is announced.

Don't

The same PIN in the clear. It is readable over a shoulder and spoken aloud by a screen reader.