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 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 code
<PinInput type="numeric" aria-label="Numeric code" />
<PinInput type="text" aria-label="Text code" />

Every size:

Show 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 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 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 — hasError sets aria-invalid and 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" with aria-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 length matched 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 label is fine and goes through Field.

Content

  • aria-label names what the code is for (“Verification code”, “2FA code”) — keep it short; it’s announced once for the whole group.