Skip to content
Ndop UI

Input OTP

Enters a one-time code, digit by digit, in one field that autofill and screen readers understand.

  • v1.0.0
  • SSR
  • Zoneless
  • 3.2 kB gzip
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
  InputOtp,
  InputOtpGroup,
  InputOtpInput,
  InputOtpSeparator,
  InputOtpSlot,
} from '@/ui/input-otp/input-otp';

/** A six-digit code in two groups of three. */
@Component({
  selector: 'example-input-otp-demo',
  imports: [
    InputOtp,
    InputOtpGroup,
    InputOtpInput,
    InputOtpSeparator,
    InputOtpSlot,
    NdIcon,
  ],
  template: `
    <div ndInputOtp [length]="6">
      <input ndInputOtpInput aria-label="Verification code" />
      <div ndInputOtpGroup>
        @for (index of [0, 1, 2]; track index) {
          <div ndInputOtpSlot [index]="index"></div>
        }
      </div>
      <div ndInputOtpSeparator><svg ndIcon="minus"></svg></div>
      <div ndInputOtpGroup>
        @for (index of [3, 4, 5]; track index) {
          <div ndInputOtpSlot [index]="index"></div>
        }
      </div>
    </div>
  `,
})
export class InputOtpDemoExample {}

An OTP input takes a one-time code, such as a verification code sent by text message, drawing each character in its own slot. It's one real input underneath, so screen readers read one field, the phone's keyboard offers the code from a message (autocomplete="one-time-code"), and pasting fills every slot at once. It keeps digits only by default; set allow for letters. Codes read left to right in every language, so its slots don't mirror.

Installation

npx @ndop/cli add input-otp

Usage

import {
  InputOtp,
  InputOtpGroup,
  InputOtpInput,
  InputOtpSeparator,
  InputOtpSlot,
} from '@/ui/input-otp/input-otp';
<div ndInputOtp [length]="6">
  <input ndInputOtpInput aria-label="Verification code" />
  <div ndInputOtpGroup>
    @for (index of [0, 1, 2]; track index) {
      <div ndInputOtpSlot [index]="index"></div>
    }
  </div>
  <div ndInputOtpSeparator><svg ndIcon="minus"></svg></div>
  <div ndInputOtpGroup>
    @for (index of [3, 4, 5]; track index) {
      <div ndInputOtpSlot [index]="index"></div>
    }
  </div>
</div>

Examples

Input OTP pattern

A code of letters and digits.

import { Component } from '@angular/core';
import {
  InputOtp,
  InputOtpGroup,
  InputOtpInput,
  InputOtpSlot,
} from '@/ui/input-otp/input-otp';
import { Label } from '@/ui/label/label';

/**
 * A recovery code of capital letters and digits: `allow` keeps them, and
 * the text keyboard opens on phones.
 */
@Component({
  selector: 'example-input-otp-pattern',
  imports: [InputOtp, InputOtpGroup, InputOtpInput, InputOtpSlot, Label],
  template: `
    <div class="grid gap-2">
      <label ndLabel for="input-otp-pattern-code">Recovery code</label>
      <div ndInputOtp [length]="6" allow="[A-Z0-9]" inputMode="text">
        <input
          ndInputOtpInput
          id="input-otp-pattern-code"
          autocapitalize="characters"
        />
        <div ndInputOtpGroup>
          @for (index of [0, 1, 2, 3, 4, 5]; track index) {
            <div ndInputOtpSlot [index]="index"></div>
          }
        </div>
      </div>
    </div>
  `,
})
export class InputOtpPatternExample {}

Input OTP verify

A verification step that submits the code as soon as it's complete.

We sent a code to ada@example.com.

import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import { Button } from '@/ui/button/button';
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from '@/ui/form-field/form-field';
import {
  InputOtp,
  InputOtpGroup,
  InputOtpInput,
  InputOtpSeparator,
  InputOtpSlot,
} from '@/ui/input-otp/input-otp';

/**
 * A verification step that checks the code as soon as it's complete,
 * without a submit button: a wrong code turns the field invalid and says
 * so. Here, 123456 is the right code.
 */
@Component({
  selector: 'example-input-otp-verify',
  imports: [
    Button,
    Field,
    FieldDescription,
    FieldError,
    FieldLabel,
    InputOtp,
    InputOtpGroup,
    InputOtpInput,
    InputOtpSeparator,
    InputOtpSlot,
    NdIcon,
  ],
  template: `
    <div ndField class="w-fit">
      <label ndFieldLabel for="input-otp-verify-code">
        Verification code
      </label>
      <p ndFieldDescription>We sent a code to ada&#64;example.com.</p>
      <div
        ndInputOtp
        [length]="6"
        [invalid]="status() === 'wrong'"
        [(value)]="code"
        (valueChange)="status.set('pending')"
        (completed)="verify($event)"
      >
        <input ndInputOtpInput id="input-otp-verify-code" />
        <div ndInputOtpGroup>
          @for (index of [0, 1, 2]; track index) {
            <div ndInputOtpSlot [index]="index"></div>
          }
        </div>
        <div ndInputOtpSeparator><svg ndIcon="minus"></svg></div>
        <div ndInputOtpGroup>
          @for (index of [3, 4, 5]; track index) {
            <div ndInputOtpSlot [index]="index"></div>
          }
        </div>
      </div>
      <p ndFieldError>That code isn't right. Check it, or send a new one.</p>
      <p class="text-sm" aria-live="polite">
        @if (status() === 'verified') {
          Verified.
        }
      </p>
      <button
        ndButton
        variant="link"
        class="justify-self-start px-0"
        (click)="resend()"
      >
        Send a new code
      </button>
    </div>
  `,
})
export class InputOtpVerifyExample {
  protected readonly code = signal('');
  protected readonly status = signal<'pending' | 'wrong' | 'verified'>(
    'pending',
  );

  /**
   * Checks a complete code.
   *
   * @param code - The code.
   */
  protected verify(code: string): void {
    this.status.set(code === '123456' ? 'verified' : 'wrong');
  }

  /** Clears the code, as after sending a new one. */
  protected resend(): void {
    this.code.set('');
    this.status.set('pending');
  }
}

Input OTP disabled

A disabled code input.

import { Component } from '@angular/core';
import {
  InputOtp,
  InputOtpGroup,
  InputOtpInput,
  InputOtpSlot,
} from '@/ui/input-otp/input-otp';

/** A disabled code input, such as while a code is being checked. */
@Component({
  selector: 'example-input-otp-disabled',
  imports: [InputOtp, InputOtpGroup, InputOtpInput, InputOtpSlot],
  template: `
    <div ndInputOtp [length]="4" defaultValue="2048" disabled>
      <input ndInputOtpInput aria-label="PIN" />
      <div ndInputOtpGroup>
        @for (index of [0, 1, 2, 3]; track index) {
          <div ndInputOtpSlot [index]="index"></div>
        }
      </div>
    </div>
  `,
})
export class InputOtpDisabledExample {}

Accessibility

Keyboard

Keys the component handles.

Key Action
BackspaceDeletes the last character.

Focus

  • Clicking any slot focuses the input, with the caret at the end of the code.
  • Focus stays on the input as the code fills; the completed output lets the app move on.
  • Leaving the input marks a bound field touched.

Right-to-left

The root has dir="ltr": a code's characters read left to right in every language, as phone numbers do, so the slots don't mirror.

API reference

InputOtp

[ndInputOtp]

A one-time code input (@ndop/primitives/input-otp): one native input[ndInputOtpInput] holds the code, invisible over the slots that draw it. Set length, bind it with [(value)] or any forms library on this element, and name the input with a label or aria-label.

InputOtp binding Type Default
[class]
Extra classes, merged last so they win.
string''
[defaultValue]
Initial value when it isn't bound.
string''
[length]required
The code's length: how many slots it fills.
number—
[allow]
A regular expression one character must match to be kept, or its source. Digits by default.
RegExp/\d/
[inputMode]
The input's inputmode, which picks the on-screen keyboard.
'numeric' | 'text''numeric'
[disabled]
Disables the input. A disabled bound field does too.
booleanfalse
[invalid]
Marks the code invalid when no forms library is bound; with one, the field's state decides.
booleanfalse
[(value)]
The code. Two-way bindable with [(value)].
string''
Data attribute Values
data-slotinput-otp
data-complete —
data-disabled —
data-invalid —

InputOtpInput

input[ndInputOtpInput]role native

The native input of an InputOtp: transparent and stretched over the slots, so clicking them focuses it.

Manages aria-invalid.

InputOtpInput binding Type Default
[class]
Extra classes, merged last so they win.
string''
[id]
Element id; also the key a label[ndLabel] uses to find it.
string | undefined—
Data attribute Values
data-slotinput-otp-input
data-invalid —

InputOtpGroup

[ndInputOtpGroup]

A row of InputOtpSlots that join into one box.

Manages aria-hidden.

InputOtpGroup binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotinput-otp-group

InputOtpSlot

[ndInputOtpSlot]

One character of an InputOtp, ringed while it's where the next one goes, with a blinking caret while that slot is empty.

Manages aria-hidden.

InputOtpSlot binding Type Default
[class]
Extra classes, merged last so they win.
string''
[index]required
The character's position in the code, from 0.
number—
Data attribute Values
data-slotinput-otp-slot
data-active —
data-filled —
data-caret —
data-invalid —

InputOtpSeparator

[ndInputOtpSeparator]role separator

A separator between groups of slots. Put a minus icon inside.

Manages aria-hidden.

Data attribute Values
data-slotinput-otp-separator

Agent notes

Do

  • Name it with a label, such as "Verification code".
  • Submit the code from the completed output, so people don't have to press a button.
  • Tell people where the code was sent, and how to get a new one.

Don't

  • Don't use it for passwords or PINs people choose; use an Input.
  • Don't split the code into several inputs.