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
<PinCodelabel="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.
One input, not N
Section titled “One input, not N”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.
Variants
Section titled “Variants”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’scaret-colorfollowing 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.
Grouping
Section titled “Grouping”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.
Masking
Section titled “Masking”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".
What can be typed
Section titled “What can be typed”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.
States
Section titled “States”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>disabledfades the row and leaves the tab order, exactly as it does on every other control.aria-busyruns 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 withreadOnlyso the value cannot change under the check, rather than withdisabled, which drops the focus and the contrast.errortakes the hint slot, announces the message, marks the inputaria-invalidand 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.
API reference
Section titled “API reference”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.
Accessibility
Section titled “Accessibility”| 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 visiblelabelmatters 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 takesGrayTextand the rest takeCanvasText; the active cell adds aHighlightoutline, becausebox-shadowis dropped in that mode. That border is unconditional, which is what keepsvariant="ghost"visible there. The masked dots and the group dash are painted backgrounds, which that mode would turn intoCanvas, so they keep the cell’s system text colour (GrayTextincluded), and the caret turnsHighlight, 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 and don’t
Section titled “Do and don’t”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.