bpdm/ui
Form

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).

verify.tsx
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):

controlled.tsx
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}
    />
  );
}
verify.component.ts
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):

controlled.component.ts
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.

pin.tsx
import { InputOtp } from '@bpdm/ui/input-otp';

export function Pin() {
  return <InputOtp length={4} mask integerOnly aria-label="PIN" />;
}
pin.component.ts
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 ).

grouped.tsx
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>
  );
}
grouped.component.ts
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.

sizes.tsx
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>
  );
}
sizes.component.ts
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.

states.tsx
import { InputOtp } from '@bpdm/ui/input-otp';

export function States() {
  return <InputOtp length={6} disabled defaultValue="123456" />;
}
states.component.ts
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).

PropTypeDefaultDescription
lengthnumber6Number of cells.
valuestringControlled value. React: pair with onValueChange. Angular: two-way [(value)].
defaultValuestring""Uncontrolled initial value.
onValueChange (React)(value: string) => voidCalled with the joined value on every change.
onComplete (React) / (complete) (Angular)(value: string) => voidFired once every cell is filled — handy for auto-submit.
autoFocusbooleanfalseFocus the first cell on mount.
maskbooleanfalseHide characters (one-time PINs).
integerOnlybooleanfalseRestrict input to digits 0–9 (also sets inputmode="numeric").
sizesm | md | lgmdCell size.
groupSizenumberConnect cells into fixed groups of this size (last = remainder).
groupedbooleanfalseAuto-group into two balanced segments. Ignored if groupSize is set.
separatorReactNode (React) / string (Angular)Node/character shown between groups.
disabledbooleanfalseDisable every cell.
namestringEmits the joined value under this name via a hidden input for native form submission.
idstringGroup id (for label association / testing).
aria-labelstring"One-time code"Accessible name for the group.
aria-describedbystringIDs of describing elements (help / error text).
cellLabel(index, length) => stringCharacter N of MPer-cell screen-reader label — override for i18n. See Internationalization.
className (React) / class (Angular)stringExtra classes on the group.

Accessibility

  • The cells sit in a role="group" named by aria-label (default One-time code) — give it a meaningful name such as Verification code. Link help or error text with aria-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; integerOnly also sets inputmode="numeric" for a numeric keypad.
  • mask hides characters with -webkit-text-security while keeping a normal text input (and tells password managers to ignore it), so the real value is still reported to onValueChange.
  • The group separator is decorative (aria-hidden), so screen readers read the code as one field.
  • Setting name emits 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, default Character 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.

On this page