OTP Input
One-time-code input with auto-advance, paste-to-fill, masking and grouping — sizes and states, in React (@bpdm/ui) and Angular (@bpdm/ng).
The OTP Input is a one-time-code field — one box per character, with auto-advance,
backspace-to-previous, arrow-key navigation, and paste-to-fill (paste the whole code into any
box). The value is a plain string; it runs controlled (value + onValueChange) or uncontrolled
(defaultValue). In React it's <InputOtp>; in Angular it's <bpdm-input-otp>.
Usage
Set the number of boxes with length. Add integerOnly to accept digits only (typical for SMS /
authenticator codes).
import { InputOtp } from '@bpdm/ui/input-otp';
export function Verify() {
return <InputOtp length={6} integerOnly aria-label="Verification code" />;
}For a controlled field, pass value and onValueChange — and use onComplete to fire your check
the moment every box is filled (autoFocus puts the cursor in the first box on mount):
import { useState } from 'react';
import { InputOtp } from '@bpdm/ui/input-otp';
export function Controlled({ onVerify }: { onVerify: (code: string) => void }) {
const [code, setCode] = useState('');
return (
<InputOtp
length={6}
integerOnly
autoFocus
value={code}
onValueChange={setCode}
onComplete={onVerify}
/>
);
}import { Component } from '@angular/core';
import { BpdmInputOtp } from '@bpdm/ng';
@Component({
selector: 'app-verify',
standalone: true,
imports: [BpdmInputOtp],
template: `<bpdm-input-otp [length]="6" integerOnly aria-label="Verification code" />`,
})
export class VerifyComponent {}For a controlled field, use two-way binding on value, and (complete) to fire your check the
moment every box is filled (autoFocus puts the cursor in the first box on mount):
import { Component } from '@angular/core';
import { BpdmInputOtp } from '@bpdm/ng';
@Component({
selector: 'app-controlled',
standalone: true,
imports: [BpdmInputOtp],
template: `
<bpdm-input-otp [length]="6" integerOnly autoFocus [(value)]="code" (complete)="verify($event)" />
`,
})
export class ControlledComponent {
code = '';
verify(code: string) {
// submit the code
}
}Masked (PIN)
For a PIN or other secret code, add mask to render dots instead of the characters. The real value
is still reported through onValueChange.
import { InputOtp } from '@bpdm/ui/input-otp';
export function Pin() {
return <InputOtp length={4} mask integerOnly aria-label="PIN" />;
}import { Component } from '@angular/core';
import { BpdmInputOtp } from '@bpdm/ng';
@Component({
selector: 'app-pin',
standalone: true,
imports: [BpdmInputOtp],
template: `<bpdm-input-otp [length]="4" mask integerOnly aria-label="PIN" />`,
})
export class PinComponent {}Grouping
grouped splits the boxes into even halves with a separator; groupSize groups every N boxes
instead. Set the separator character with separator (default −).
import { InputOtp } from '@bpdm/ui/input-otp';
export function Grouped() {
return (
<div className="flex flex-col items-center gap-4">
<InputOtp length={6} grouped separator="−" integerOnly />
<InputOtp length={9} groupSize={3} separator="−" integerOnly />
</div>
);
}import { Component } from '@angular/core';
import { BpdmInputOtp } from '@bpdm/ng';
@Component({
selector: 'app-grouped',
standalone: true,
imports: [BpdmInputOtp],
template: `
<div class="flex flex-col items-center gap-4">
<bpdm-input-otp [length]="6" grouped separator="−" integerOnly />
<bpdm-input-otp [length]="9" [groupSize]="3" separator="−" integerOnly />
</div>
`,
})
export class GroupedComponent {}Sizes
Three cell sizes — sm, md (default), and lg.
import { InputOtp } from '@bpdm/ui/input-otp';
export function Sizes() {
return (
<div className="flex flex-col items-center gap-4">
<InputOtp length={4} size="sm" integerOnly />
<InputOtp length={4} size="md" integerOnly />
<InputOtp length={4} size="lg" integerOnly />
</div>
);
}import { Component } from '@angular/core';
import { BpdmInputOtp } from '@bpdm/ng';
@Component({
selector: 'app-sizes',
standalone: true,
imports: [BpdmInputOtp],
template: `
<div class="flex flex-col items-center gap-4">
<bpdm-input-otp [length]="4" size="sm" integerOnly />
<bpdm-input-otp [length]="4" size="md" integerOnly />
<bpdm-input-otp [length]="4" size="lg" integerOnly />
</div>
`,
})
export class SizesComponent {}States
Disable the whole field while a request is in flight or the code is locked.
import { InputOtp } from '@bpdm/ui/input-otp';
export function States() {
return <InputOtp length={6} disabled defaultValue="123456" />;
}import { Component } from '@angular/core';
import { BpdmInputOtp } from '@bpdm/ng';
@Component({
selector: 'app-states',
standalone: true,
imports: [BpdmInputOtp],
template: `<bpdm-input-otp [length]="6" disabled defaultValue="123456" />`,
})
export class StatesComponent {}API
InputOtp / bpdm-input-otp
The value is a plain string of the entered characters ("" when empty).
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | Number of cells. |
value | string | — | Controlled value. React: pair with onValueChange. Angular: two-way [(value)]. |
defaultValue | string | "" | Uncontrolled initial value. |
onValueChange (React) | (value: string) => void | — | Called with the joined value on every change. |
onComplete (React) / (complete) (Angular) | (value: string) => void | — | Fired once every cell is filled — handy for auto-submit. |
autoFocus | boolean | false | Focus the first cell on mount. |
mask | boolean | false | Hide characters (one-time PINs). |
integerOnly | boolean | false | Restrict input to digits 0–9 (also sets inputmode="numeric"). |
size | sm | md | lg | md | Cell size. |
groupSize | number | — | Connect cells into fixed groups of this size (last = remainder). |
grouped | boolean | false | Auto-group into two balanced segments. Ignored if groupSize is set. |
separator | ReactNode (React) / string (Angular) | − | Node/character shown between groups. |
disabled | boolean | false | Disable every cell. |
name | string | — | Emits the joined value under this name via a hidden input for native form submission. |
id | string | — | Group id (for label association / testing). |
aria-label | string | "One-time code" | Accessible name for the group. |
aria-describedby | string | — | IDs of describing elements (help / error text). |
cellLabel | (index, length) => string | Character N of M | Per-cell screen-reader label — override for i18n. See Internationalization. |
className (React) / class (Angular) | string | — | Extra classes on the group. |
Accessibility
- The cells sit in a
role="group"named byaria-label(defaultOne-time code) — give it a meaningful name such asVerification code. Link help or error text witharia-describedby. - Each cell is a labelled
<input>(Character N of M) with full keyboard support: type to auto-advance, Backspace to clear and step back, ←/→ to move between cells, and paste-to-fill from any cell. - The first cell carries
autocomplete="one-time-code", so iOS/Android offer to fill an SMS code;integerOnlyalso setsinputmode="numeric"for a numeric keypad. maskhides characters with-webkit-text-securitywhile keeping a normal text input (and tells password managers to ignore it), so the real value is still reported toonValueChange.- The group separator is decorative (
aria-hidden), so screen readers read the code as one field. - Setting
nameemits the joined value through a hidden input, so the field submits with a native<form>.
Internationalization
- The group name (
aria-label) and the per-cell label (cellLabel, defaultCharacter N of M) are translatable — see Translating labels. - RTL-safe: the connected-cell rounding uses logical properties and the arrow keys follow
the visual order (ArrowLeft advances in
dir="rtl");autocomplete="one-time-code"still triggers SMS autofill.
Password Input
Password field with a show/hide toggle and an optional strength meter — sizes and states, in React (@bpdm/ui) and Angular (@bpdm/ng).
Secure Field
Masked input for secrets — API keys, license keys, tokens — with reveal and copy, grouping and a visible tail, in React (@bpdm/ui) and Angular (@bpdm/ng).