Source: https://monarchui.dev/components/otp-pin-input/

Components · Forms

# OTPPinInput

One-time code or PIN entry in separate boxes, backed by one accessible input.

@rookcom/monarch-react

Component

## Verify a code

## Usage examples

### PIN and groups

## Purpose

Enters a short numeric or alphanumeric code (an email or SMS one-time code, a step-up PIN) shown as one box per character, while remaining a single real input for autofill, paste, mobile keypads and screen readers.

### Use when

- Sign-in or step-up verification with a code sent to the user.
- A short PIN where the box per character shows how many are needed.

### Avoid when

- Passwords or long secrets: use a password Input.
- Codes of variable length: use Input.

## Design principles

- One field, many boxes: the boxes are a picture of one `<input>`, so the platform features for one-time codes just work.
- Forgiving entry: spaces, dashes and other characters in a pasted or typed code are dropped.
- Verification is the product’s: `onComplete` hands over the code, and the product shows the result through Field.

## API reference

```tsx
import { OTPPinInput } from '@rookcom/monarch-react';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `OTPPinInput` | component | client | `.mn-otp-pin-input` |

Renders `.mn-otp-pin-input` with `aria-hidden` slots and a transparent `<input>` laid over them. The ref and native input props go to the input; `className` goes to the wrapper.

Reads Field context through `useFieldControl` for its label, description, `invalid` and `disabled`.

Generated from the TypeScript declarations of `packages/react/src/components/forms/otp-pin-input`.

## Properties

`maxLength`, `minLength` and `pattern` are set by the component from `length` and `mode` and cannot be passed.

### `OTPPinInput`

A one-time code or PIN entry shown as separate boxes. It is one real `<input>` underneath, so autofill from SMS or email (`autocomplete="one-time-code"`), paste, the numeric keypad and screen readers all treat it as a single field. Give it a label with Field or `aria-label`.

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` | Class for the outer wrapper (the real input is `.mn-otp-pin-input__input`). |
| `defaultValue` | `string` | Initial code when uncontrolled. |
| `disabled` | `boolean` |  |
| `groupSize` | `number` | Draw a gap after every N slots, e.g. 3 for "123 456". Visual only. |
| `id` | `string` |  |
| `invalid` | `boolean` | Marks the code invalid (e.g. after a failed verification). Inside a Field, the Field's `invalid` is used. |
| `length` | `number` | Number of characters. Default 6. |
| `mask` | `boolean` | Show dots instead of the characters, for a PIN typed on a shared screen. |
| `mode` | `"numeric" \| "alphanumeric"` | `numeric` (default) accepts digits and opens the number keypad; `alphanumeric` accepts A–Z and 0–9, upper-cased. |
| `onComplete` | `((value: string) => void)` | Called once each time the code reaches `length` characters (typed, pasted or autofilled). |
| `onValueChange` | `((value: string) => void)` | Called with the cleaned code on every change. |
| `size` | `"md" \| "lg"` |  |
| `value` | `string` | The code (controlled). |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `length` | `'number (default 6)'` | Number of boxes and characters. |
| `mode` | `'numeric'``'alphanumeric'` | `numeric` keeps digits and opens the number keypad; `alphanumeric` keeps A–Z and 0–9, upper-cased. |
| `mask` | `'boolean'` | Dots instead of characters; the input becomes `type="password"`. |
| `groupSize` | `'number'` | A visual gap after every N boxes (e.g. 3 for "123 456"). |
| `size` | `'md'``'lg'` | Box size. Default `md`. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Focused | `data-focused` | The next box to fill shows the focus ring and a blinking caret. |
| Filled | `data-filled on a slot` | The box shows its character or a dot. |
| Complete | `data-complete` | Every box is filled; `onComplete` has fired. |
| Invalid | `data-invalid, aria-invalid="true"` | Danger borders, from `invalid` or the Field. |
| Disabled | `data-disabled, disabled` | Muted boxes, for example while verifying. |

## Interaction behaviour

- Typing fills the boxes in order; characters that do not fit the `mode` are dropped.
- Pasting a full code replaces the value; pasting a fragment appends it. Spaces and dashes are removed.
- Autofill from SMS or email (`autocomplete="one-time-code"`) fills every box at once.
- `onComplete` fires each time the code reaches `length` characters, whether typed, pasted or autofilled.
- The caret always stays at the end, so editing is by Backspace; clicking any box focuses the field.
- Controlled with `value` + `onValueChange` (set `""` to reset after a failed attempt), or uncontrolled with `defaultValue`.

## Keyboard support

| Keys | Action |
| --- | --- |
| Characters | Fill the next box. |
| Backspace | Clears the last filled box. |
| Ctrl/⌘ + V | Pastes a code. |
| Tab | Moves focus in and out as one field. |

## Accessibility

Role

textbox (one native input)

Accessible name

A FieldLabel or

aria-label

such as "6-digit code". Screen readers hear one field and its value, not six unnamed boxes.

- The visual boxes are `aria-hidden`.
- Errors and help come from Field and are linked with `aria-describedby`.
- `inputmode="numeric"` and `pattern="[0-9]*"` open the number keypad on phones without blocking paste.
- With `mask`, the input is `type="password"`, so assistive technology does not read the PIN aloud.

## Responsive behaviour

- Boxes are 2.75rem wide (3.25rem at `lg`) and shrink evenly when the container is narrower, down to 1.5rem each, so even an 8-character grouped code fits a 320px screen. In a shrink-to-fit container (inline or `width: max-content`) give the field a width, or the boxes stay at that minimum.
- The input font size is 16px, which keeps iOS from zooming on focus.

## Theme support

- Box borders, surfaces and the focus ring are semantic tokens, so light, dark and more-contrast themes apply.
- The caret blink and box transitions stop under `prefers-reduced-motion`.

## Do and don't

### Do

- Verify in `onComplete` and also offer a Confirm button for users who type slowly.
- Disable the field (through Field) while the code is verified, and clear it after a wrong code.
- Tell the user where the code was sent and how many characters it has.

### Don't

- Do not build one input per box; autofill and screen readers break.
- Do not reveal whether a code was close; just say it was not right.

## Related components

- [Input](https://monarchui.dev/components/text-field/): Input for codes of variable length.
- [Field](https://monarchui.dev/components/form-field/): Label, help and error wiring.
- [Button](https://monarchui.dev/components/button/): The Confirm action next to it.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [Field](https://monarchui.dev/components/form-field/) |

## Testing coverage

- One labelled input with `one-time-code`, numeric keypad and pattern; slots are aria-hidden.
- Typing fills slots, drops other characters, and `onComplete` fires once at full length.
- Backspace deletes.
- Pasting a full code (with spaces and dashes) replaces; a fragment appends.
- Other lengths, alphanumeric mode with upper-casing, and groups.
- Mask shows dots and uses a password input.
- Controlled mode and reset by the parent.
- Reads Field label, error, invalid and disabled.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery; Cross-browser axe: /components/otp-pin-input |

Tests: `packages/react/src/components/forms/otp-pin-input`

## All example code

### Verify a code

```tsx
import { useState } from 'react';
import { Button, Field, FieldDescription, FieldError, FieldLabel, OTPPinInput } from '@rookcom/monarch-react';

function VerifyDemo() {
  const [code, setCode] = useState('');
  const [state, setState] = useState<'idle' | 'checking' | 'wrong' | 'ok'>('idle');
  const verify = (value: string) => {
    setState('checking');
    // Demo only: the product verifies the code on its server.
    setTimeout(() => setState(value === '123456' ? 'ok' : 'wrong'), 500);
  };
  return (
    <div style={{ display: 'grid', gap: 12, width: '100%' }}>
      <Field invalid={state === 'wrong'} disabled={state === 'checking'}>
        <FieldLabel>Verification code</FieldLabel>
        <OTPPinInput
          value={code}
          onValueChange={(v) => {
            setCode(v);
            if (state === 'wrong') setState('idle');
          }}
          onComplete={verify}
        />
        <FieldDescription>Try 123456.</FieldDescription>
        <FieldError>
          {state === 'wrong' ? 'That code is not right. Check it and try again.' : null}
        </FieldError>
      </Field>
      <div>
        <Button
          variant="primary"
          loading={state === 'checking'}
          disabled={code.length !== 6}
          onClick={() => verify(code)}
        >
          {state === 'ok' ? 'Verified' : 'Confirm'}
        </Button>
      </div>
    </div>
  );
}

<VerifyDemo />
```

### PIN and groups

```tsx
import { OTPPinInput } from '@rookcom/monarch-react';

<div style={{ display: 'grid', gap: 16, width: '100%' }}>
  <OTPPinInput aria-label="4-digit PIN" length={4} mask defaultValue="25" />
  <OTPPinInput aria-label="Grouped code" groupSize={3} defaultValue="4829" />
  <OTPPinInput
    aria-label="Recovery code"
    length={8}
    mode="alphanumeric"
    groupSize={4}
    defaultValue="K7P2"
  />
</div>
```
