PinInput
A row of single-character boxes for short codes (OTP, 2FA).
Last updated
Uses JavaScript
Auto-advances focus to the next box on input, moves back on Backspace from
an empty box, supports arrow-key navigation, and distributes a pasted code
across the boxes — then joins every box’s value into the hidden code field
(and un-names the boxes so they don’t double-submit).
Without JS: Each box keeps its own `name` attribute, so without JS every character submits as a repeated `code` field — join them server-side (`formData.getAll('code').join('')`) to reconstruct the code. With JS, the boxes are un-named and the joined value submits via the single hidden field instead.
Show codeHide code
<PinInput />Add
npx @ava-ui/apogee add pin-input
No Apogee dependencies, no icons. Ships one scoped JS island that joins the boxes
into a single value; without it each character still submits as a repeated name
field (see the note above).
Usage
---
import PinInput from '@/components/apogee/PinInput/PinInput.astro'
---
<PinInput name="code" length={6} />
API
@composable — exposed to Content Editors in AVA. PinInput’s Props has no
index signature, so — unlike other components on this page — attributes beyond
those below are not forwarded to the DOM.
| Prop | Type | Default | Description |
|---|---|---|---|
name |
string |
'code' |
name of the hidden <input type="hidden"> that carries the joined value. |
length |
number |
6 |
Number of character boxes. |
pattern |
'numeric' | 'alphabetic' | 'alphanumeric' |
'numeric' |
Accepted characters: filters as you type and sets the native pattern and keypad. |
type |
'numeric' | 'text' |
— | Older alias: numeric ⇒ pattern="numeric", text ⇒ alphanumeric. |
hasError |
boolean |
false |
Critical border on every box, aria-invalid. |
disabled |
boolean |
false |
Disables every box. |
value |
string |
— | Initial code, one character per box. |
aria-label |
string |
'Verification code' |
Label on the role="group" wrapper when there is no label. |
id |
string |
generated | Id of the first box; the label points at it. |
label |
string |
— | Visible label through Field; the group is aria-labelledby it. |
hint |
string |
— | Helper text under the boxes. |
error |
string |
— | Error message; marks every box invalid. |
groups |
number |
— | Boxes per group, with a separator between groups (length={6} groups={3} → 123 – 456). |
size |
'small' | 'medium' | 'large' | 'xlarge' |
'medium' |
Cell size: 36 / 44 / 48 / 56px. |
variant |
'outline' | 'faded' | 'ghost' | 'headless' |
'outline' |
The cell box style, shared with every text field. |
class |
string |
— | Extra classes on the root, appended last. |
Variants
Show codeHide code
<PinInput type="numeric" aria-label="Numeric code" />
<PinInput type="text" aria-label="Text code" />Every size:
Show codeHide code
<PinInput size="small" aria-label="Small code" />
<PinInput size="medium" aria-label="Medium code" />
<PinInput size="large" aria-label="Large code" />
<PinInput size="xlarge" aria-label="Extra large code" />Label, groups and the filled state
A filled box takes the primary border; an empty one shows a faint placeholder.
From the text message.
Show codeHide code
<PinInput label="Code" hint="From the text message." length={6} groups={3} name="pin-grouped" />
<PinInput size="xlarge" aria-label="Large code" length={4} name="pin-xl" />Patterns
Every pattern:
Show codeHide code
<PinInput pattern="numeric" aria-label="Numeric code" />
<PinInput pattern="alphabetic" aria-label="Letters" />
<PinInput pattern="alphanumeric" aria-label="Mixed" />States
- Focus — typing moves focus to the next box; Backspace on an empty box moves back.
- Error —
hasErrorsetsaria-invalidand a critical border on every box. - Disabled — every box is disabled and dimmed.
Examples
A shorter PIN with a custom field name:
<PinInput length={4} name="pin" aria-label="4-digit PIN" />
Accessibility
| Key | Action |
|---|---|
Tab Shift Tab |
Move between boxes. |
← → |
Move to the previous / next box without changing values. |
Backspace |
In an empty box, moves focus to the previous box. |
⌘/Ctrl V |
Pastes a code, filling forward from the focused box. |
role="group"witharia-label(defaults to “Verification code”) groups the boxes for assistive tech.- Each box carries
aria-label="Character N"(1-indexed) — there’s no visible per-box label. - Typing a character moves focus to the next box.
- The first box gets
autocomplete="one-time-code", so browsers/OS can offer SMS-code autofill. - No JS: every box renders, is labeled, and accepts a character; each box
keeps its
name, so it submits as a repeated field — join the values server-side to reconstruct the code.
Tokens
| Token | Used for |
|---|---|
--border-neutral |
Box border |
--background-page |
Box background |
--foreground-neutral |
Box text |
--background-primary |
Focus-visible border |
--border-primary |
Focus border |
--radius-md |
Box corner radius |
Do / Don’t
- Do set
type="numeric"for OTP/PIN codes — it filters input and shows a numeric keypad on mobile. - Do keep
lengthmatched to the real code length the backend expects. - Don’t forget the no-JS path submits separate characters, not a joined
string — join
formData.getAll(name)server-side to reconstruct the code. - Don’t draw a border around the group; the boxes are the control. A visible
labelis fine and goes through Field.
Content
aria-labelnames what the code is for (“Verification code”, “2FA code”) — keep it short; it’s announced once for the whole group.