# PinInput

> A row of single-character boxes for short codes (OTP, 2FA).

URL: https://apogee.avaui.dev/docs/pin-input
Last updated: 2026-10-15

A row of single-character boxes for short codes (OTP, 2FA).

Last updated October 15, 2026

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.

```astro
<PinInput />
```

## Add

```sh
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

```astro
---
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

```astro
<PinInput type="numeric" aria-label="Numeric code" />
<PinInput type="text" aria-label="Text code" />
```

Every `size`:

```astro
<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.

```astro
<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`:

```astro
<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:

```astro
<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.

## Related

[

InputA labeled text field with hint/error messaging, wired for accessibility — plus a pure-CSS floating label.

](https://apogee.avaui.dev/docs/input)[

FieldThe labeled-field scaffold — label, control, and hint/error — wired for accessibility.

](https://apogee.avaui.dev/docs/field)[

NumberInputA labeled numeric field with styled −/+ steppers, wired like Input.

](https://apogee.avaui.dev/docs/number-input)
