AvesraAvesrabeta

Input OTP

A one-time password input component for verification codes and secure authentication. Apply av-input-otp to a container <div> with slot children.

Import

import { AvInputOtpImports } from '@avesra/angular';

Compose with av-label, av-description, and av-field-error. For form-level validation and submission, see Form.

Usage

We've sent a code to a****@gmail.com

Didn't receive a code?

Resend
import { Component } from '@angular/core';
import {
  AvInputOtpImports,
  AvLabelComponent,
  AvLinkImports,
} from '@avesra/angular';

@Component({
  selector: 'app-input-otp-basic-demo',
  imports: [
    AvInputOtpImports,
    AvLabelComponent,
    AvLinkImports,
  ],
  host: { class: 'flex w-full max-w-[280px] flex-col gap-2' },
  template: `<div class="flex flex-col gap-1">
  <label av-label>Verify account</label>
  <p class="text-sm text-muted">We've sent a code to a****&#64;gmail.com</p>
</div>
<div av-input-otp [maxLength]="6">
  <div av-input-otp-group>
    <div av-input-otp-slot [index]="0"></div>
    <div av-input-otp-slot [index]="1"></div>
    <div av-input-otp-slot [index]="2"></div>
  </div>
  <div av-input-otp-separator></div>
  <div av-input-otp-group>
    <div av-input-otp-slot [index]="3"></div>
    <div av-input-otp-slot [index]="4"></div>
    <div av-input-otp-slot [index]="5"></div>
  </div>
</div>
<div class="flex items-center gap-[5px] px-1 pt-1">
  <p class="text-sm text-muted">Didn't receive a code?</p>
  <a av-link class="text-foreground" underline="always" href="#" (click)="$event.preventDefault()">Resend</a>
</div>`,
})
export class InputOtpBasicDemo {}

Anatomy

Import the Input OTP parts and compose them with attribute selectors on <div> hosts.

<div av-input-otp [maxLength]="6">
  <div av-input-otp-group>
    <div av-input-otp-slot [index]="0"></div>
    <div av-input-otp-slot [index]="1"></div>
    <!-- ...rest of the slots -->
  </div>
  <div av-input-otp-separator></div>
  <div av-input-otp-group>
    <div av-input-otp-slot [index]="3"></div>
    <!-- ...rest of the slots -->
  </div>
</div>

Input OTP uses a single hidden native input overlaid on visual slot elements. Each slot requires a unique zero-based [index].

Variants

The Input OTP component supports two visual variants:

  • primary (default) — Standard styling with shadow, suitable for most use cases
  • secondary — Lower emphasis variant without shadow, suitable for use in Surface components
import { Component } from '@angular/core';
import {
  AvInputOtpImports,
  AvLabelComponent,
} from '@avesra/angular';

@Component({
  selector: 'app-input-otp-variants-demo',
  imports: [
    AvInputOtpImports,
    AvLabelComponent,
  ],
  host: { class: 'flex w-full max-w-[280px] flex-col gap-6' },
  template: `<div class="flex flex-col gap-2">
  <label av-label>Primary variant</label>
  <div av-input-otp variant="primary" [maxLength]="6">
    <div av-input-otp-group>
      <div av-input-otp-slot [index]="0"></div>
      <div av-input-otp-slot [index]="1"></div>
      <div av-input-otp-slot [index]="2"></div>
    </div>
    <div av-input-otp-separator></div>
    <div av-input-otp-group>
      <div av-input-otp-slot [index]="3"></div>
      <div av-input-otp-slot [index]="4"></div>
      <div av-input-otp-slot [index]="5"></div>
    </div>
  </div>
</div>
<div class="flex flex-col gap-2">
  <label av-label>Secondary variant</label>
  <div av-input-otp variant="secondary" [maxLength]="6">
    <div av-input-otp-group>
      <div av-input-otp-slot [index]="0"></div>
      <div av-input-otp-slot [index]="1"></div>
      <div av-input-otp-slot [index]="2"></div>
    </div>
    <div av-input-otp-separator></div>
    <div av-input-otp-group>
      <div av-input-otp-slot [index]="3"></div>
      <div av-input-otp-slot [index]="4"></div>
      <div av-input-otp-slot [index]="5"></div>
    </div>
  </div>
</div>`,
})
export class InputOtpVariantsDemo {}

In Surface

When used inside a Surface component, use variant="secondary" to apply the lower emphasis variant suitable for surface backgrounds.

We've sent a code to a****@gmail.com

Didn't receive a code?

Resend
import { Component } from '@angular/core';
import {
  AvInputOtpImports,
  AvLabelComponent,
  AvLinkImports,
  AvSurfaceComponent,
} from '@avesra/angular';

@Component({
  selector: 'app-input-otp-on-surface-demo',
  imports: [
    AvInputOtpImports,
    AvLabelComponent,
    AvLinkImports,
    AvSurfaceComponent,
  ],
  host: { class: 'flex w-full flex-col gap-2' },
  template: `<div av-surface class="flex w-full flex-col gap-2 rounded-3xl p-6">
  <div class="flex flex-col gap-1">
    <label av-label>Verify account</label>
    <p class="text-sm text-muted">We've sent a code to a****&#64;gmail.com</p>
  </div>
  <div av-input-otp variant="secondary" [maxLength]="6">
    <div av-input-otp-group>
      <div av-input-otp-slot [index]="0"></div>
      <div av-input-otp-slot [index]="1"></div>
      <div av-input-otp-slot [index]="2"></div>
    </div>
    <div av-input-otp-separator></div>
    <div av-input-otp-group>
      <div av-input-otp-slot [index]="3"></div>
      <div av-input-otp-slot [index]="4"></div>
      <div av-input-otp-slot [index]="5"></div>
    </div>
  </div>
  <div class="flex items-center gap-[5px] px-1 pt-1">
    <p class="text-sm text-muted">Didn't receive a code?</p>
    <a av-link class="text-foreground" underline="always" href="#" (click)="$event.preventDefault()">Resend</a>
  </div>
</div>`,
})
export class InputOtpOnSurfaceDemo {}

Disabled State

Set disabled on the root to prevent input and apply disabled styling to slots. Pair with a disabled av-label when the whole field is inactive.

Code verification is currently disabled

import { Component } from '@angular/core';
import {
  AvDescriptionComponent,
  AvInputOtpImports,
  AvLabelComponent,
} from '@avesra/angular';

@Component({
  selector: 'app-input-otp-disabled-demo',
  imports: [
    AvInputOtpImports,
    AvLabelComponent,
    AvDescriptionComponent,
  ],
  host: { class: 'flex w-full max-w-[280px] flex-col gap-2' },
  template: `<label av-label disabled>Verify account</label>
<p av-description>Code verification is currently disabled</p>
<div av-input-otp disabled [maxLength]="6">
  <div av-input-otp-group>
    <div av-input-otp-slot [index]="0"></div>
    <div av-input-otp-slot [index]="1"></div>
    <div av-input-otp-slot [index]="2"></div>
  </div>
  <div av-input-otp-separator></div>
  <div av-input-otp-group>
    <div av-input-otp-slot [index]="3"></div>
    <div av-input-otp-slot [index]="4"></div>
    <div av-input-otp-slot [index]="5"></div>
  </div>
</div>`,
})
export class InputOtpDisabledDemo {}

Four Digits

import { Component } from '@angular/core';
import {
  AvInputOtpImports,
  AvLabelComponent,
} from '@avesra/angular';

@Component({
  selector: 'app-input-otp-four-digits-demo',
  imports: [AvInputOtpImports, AvLabelComponent],
  host: { class: 'flex w-full max-w-[280px] flex-col gap-2' },
  template: `<label av-label>Enter PIN</label>
<div av-input-otp [maxLength]="4">
  <div av-input-otp-group>
    <div av-input-otp-slot [index]="0"></div>
    <div av-input-otp-slot [index]="1"></div>
    <div av-input-otp-slot [index]="2"></div>
    <div av-input-otp-slot [index]="3"></div>
  </div>
</div>`,
})
export class InputOtpFourDigitsDemo {}

Controlled

Control the value with [(value)] to synchronize with state, clear the input, or implement custom validation. For Angular forms, bind with [(ngModel)] (FormsModule) or formControlName / [formControl] (ReactiveFormsModule) — Input OTP implements ControlValueAccessor.

Enter a 6-digit code

import { Component, signal } from '@angular/core';
import {
  AvDescriptionComponent,
  AvInputOtpImports,
  AvLabelComponent,
} from '@avesra/angular';

@Component({
  selector: 'app-input-otp-controlled-demo',
  imports: [
    AvInputOtpImports,
    AvLabelComponent,
    AvDescriptionComponent,
  ],
  host: { class: 'flex w-full max-w-[280px] flex-col gap-2' },
  template: `<label av-label>Verify account</label>
<div av-input-otp [maxLength]="6" [(value)]="value">
  <div av-input-otp-group>
    <div av-input-otp-slot [index]="0"></div>
    <div av-input-otp-slot [index]="1"></div>
    <div av-input-otp-slot [index]="2"></div>
  </div>
  <div av-input-otp-separator></div>
  <div av-input-otp-group>
    <div av-input-otp-slot [index]="3"></div>
    <div av-input-otp-slot [index]="4"></div>
    <div av-input-otp-slot [index]="5"></div>
  </div>
</div>
<p av-description>
  @if (value().length > 0) {
    Value: {{ value() }} ({{ value().length }}/6) •
    <button type="button" class="font-medium text-foreground underline" (click)="clear()">Clear</button>
  } @else {
    Enter a 6-digit code
  }
</p>`,
})
export class InputOtpControlledDemo {
  value = signal('');

  clear(): void {
    this.value.set('');
  }
}

On Complete

Use the (complete) output to trigger actions when all slots are filled.

import { Component, signal } from '@angular/core';
import {
  AvButtonComponent,
  AvFormComponent,
  AvInputOtpImports,
  AvLabelComponent,
} from '@avesra/angular';

@Component({
  selector: 'app-input-otp-on-complete-demo',
  imports: [
    AvInputOtpImports,
    AvLabelComponent,
    AvFormComponent,
    AvButtonComponent,
  ],
  host: { class: 'flex w-full justify-center' },
  template: `<form av-form class="flex w-full max-w-[280px] flex-col gap-2" (submit)="onSubmit($event)">
  <label av-label>Verify account</label>
  <div
    av-input-otp
    [maxLength]="6"
    [(value)]="value"
    (valueChange)="onChange($event)"
    (complete)="onComplete($event)"
  >
    <div av-input-otp-group>
      <div av-input-otp-slot [index]="0"></div>
      <div av-input-otp-slot [index]="1"></div>
      <div av-input-otp-slot [index]="2"></div>
    </div>
    <div av-input-otp-separator></div>
    <div av-input-otp-group>
      <div av-input-otp-slot [index]="3"></div>
      <div av-input-otp-slot [index]="4"></div>
      <div av-input-otp-slot [index]="5"></div>
    </div>
  </div>
  <button
    av-button
    class="mt-2 w-full"
    type="submit"
    variant="primary"
    [disabled]="!isComplete()"
    [pending]="isSubmitting()"
  >
    @if (isSubmitting()) {
      Verifying...
    } @else {
      Verify Code
    }
  </button>
</form>`,
})
export class InputOtpOnCompleteDemo {
  value = signal('');
  isComplete = signal(false);
  isSubmitting = signal(false);

  onComplete(_code: string): void {
    this.isComplete.set(true);
  }

  onChange(value: string): void {
    this.value.set(value);
    this.isComplete.set(false);
  }

  onSubmit(event: Event): void {
    event.preventDefault();
    this.isSubmitting.set(true);

    setTimeout(() => {
      this.isSubmitting.set(false);
      this.value.set('');
      this.isComplete.set(false);
    }, 2000);
  }
}

Form Example

A complete two-factor authentication form with validation and submission.

Enter the 6-digit code from your authenticator app

Having trouble?

Use backup code
import { Component, signal } from '@angular/core';
import {
  AvButtonComponent,
  AvDescriptionComponent,
  AvFieldErrorComponent,
  AvFormComponent,
  AvInputOtpImports,
  AvLabelComponent,
  AvLinkImports,
} from '@avesra/angular';

@Component({
  selector: 'app-input-otp-form-example-demo',
  imports: [
    AvInputOtpImports,
    AvLabelComponent,
    AvDescriptionComponent,
    AvFieldErrorComponent,
    AvFormComponent,
    AvButtonComponent,
    AvLinkImports,
  ],
  host: { class: 'flex w-full max-w-[280px] flex-col gap-1' },
  template: `<form av-form class="flex flex-col gap-4" (submit)="onSubmit($event)">
  <div class="flex flex-col gap-2">
    <label av-label>Two-factor authentication</label>
    <p av-description>Enter the 6-digit code from your authenticator app</p>
    <div
      av-input-otp
      [maxLength]="6"
      [invalid]="!!error()"
      [(value)]="value"
      (valueChange)="onChange($event)"
    >
      <div av-input-otp-group>
        <div av-input-otp-slot [index]="0"></div>
        <div av-input-otp-slot [index]="1"></div>
        <div av-input-otp-slot [index]="2"></div>
      </div>
      <div av-input-otp-separator></div>
      <div av-input-otp-group>
        <div av-input-otp-slot [index]="3"></div>
        <div av-input-otp-slot [index]="4"></div>
        <div av-input-otp-slot [index]="5"></div>
      </div>
    </div>
    <p av-field-error id="code-error" [visible]="!!error()">{{ error() }}</p>
  </div>
  <button
    av-button
    type="submit"
    full-width
    variant="primary"
    [disabled]="value().length !== 6"
    [pending]="isSubmitting()"
  >
    @if (isSubmitting()) {
      Verifying...
    } @else {
      Verify
    }
  </button>
  <div class="flex items-center justify-center gap-1">
    <p class="text-sm text-muted">Having trouble?</p>
    <a av-link class="text-sm text-foreground" underline="always" href="#" (click)="$event.preventDefault()">
      Use backup code
    </a>
  </div>
</form>`,
})
export class InputOtpFormExampleDemo {
  value = signal('');
  error = signal('');
  isSubmitting = signal(false);

  onChange(value: string): void {
    this.value.set(value);
    this.error.set('');
  }

  onSubmit(event: Event): void {
    event.preventDefault();
    this.error.set('');

    if (this.value().length !== 6) {
      this.error.set('Please enter all 6 digits');
      return;
    }

    this.isSubmitting.set(true);

    setTimeout(() => {
      if (this.value() === '123456') {
        this.value.set('');
      } else {
        this.error.set('Invalid code. Please try again.');
      }
      this.isSubmitting.set(false);
    }, 1500);
  }
}

With Pattern

Use the pattern input to restrict characters. Avesra exports common patterns like AV_REGEXP_ONLY_CHARS and AV_REGEXP_ONLY_DIGITS.

Only alphabetic characters are allowed

import { Component } from '@angular/core';
import {
  AV_REGEXP_ONLY_CHARS,
  AvDescriptionComponent,
  AvInputOtpImports,
  AvLabelComponent,
} from '@avesra/angular';

@Component({
  selector: 'app-input-otp-with-pattern-demo',
  imports: [
    AvInputOtpImports,
    AvLabelComponent,
    AvDescriptionComponent,
  ],
  host: { class: 'flex w-full max-w-[280px] flex-col gap-2' },
  template: `<label av-label>Enter code (letters only)</label>
<p av-description>Only alphabetic characters are allowed</p>
<div av-input-otp [maxLength]="6" [pattern]="regexpOnlyChars">
  <div av-input-otp-group>
    <div av-input-otp-slot [index]="0"></div>
    <div av-input-otp-slot [index]="1"></div>
    <div av-input-otp-slot [index]="2"></div>
  </div>
  <div av-input-otp-separator></div>
  <div av-input-otp-group>
    <div av-input-otp-slot [index]="3"></div>
    <div av-input-otp-slot [index]="4"></div>
    <div av-input-otp-slot [index]="5"></div>
  </div>
</div>`,
})
export class InputOtpWithPatternDemo {
  readonly regexpOnlyChars = AV_REGEXP_ONLY_CHARS;
}

With Validation

Use invalid together with av-field-error to surface errors.

Hint: The code is 123456

import { Component, signal } from '@angular/core';
import {
  AvButtonComponent,
  AvDescriptionComponent,
  AvFieldErrorComponent,
  AvFormComponent,
  AvInputOtpImports,
  AvLabelComponent,
} from '@avesra/angular';

@Component({
  selector: 'app-input-otp-with-validation-demo',
  imports: [
    AvInputOtpImports,
    AvLabelComponent,
    AvDescriptionComponent,
    AvFieldErrorComponent,
    AvFormComponent,
    AvButtonComponent,
  ],
  host: { class: 'flex w-full max-w-[280px] flex-col gap-2' },
  template: `<form av-form class="flex flex-col gap-2" (submit)="onSubmit($event)">
  <label av-label>Verify account</label>
  <p av-description>Hint: The code is 123456</p>
  <div
    av-input-otp
    name="code"
    [maxLength]="6"
    [invalid]="isInvalid()"
    [aria-describedby]="isInvalid() ? 'code-error' : undefined"
    [(value)]="value"
    (valueChange)="onChange($event)"
  >
    <div av-input-otp-group>
      <div av-input-otp-slot [index]="0"></div>
      <div av-input-otp-slot [index]="1"></div>
      <div av-input-otp-slot [index]="2"></div>
    </div>
    <div av-input-otp-separator></div>
    <div av-input-otp-group>
      <div av-input-otp-slot [index]="3"></div>
      <div av-input-otp-slot [index]="4"></div>
      <div av-input-otp-slot [index]="5"></div>
    </div>
  </div>
  <p av-field-error id="code-error" [visible]="isInvalid()">
    Invalid code. Please try again.
  </p>
  <button av-button type="submit" [disabled]="value().length !== 6">Submit</button>
</form>`,
})
export class InputOtpWithValidationDemo {
  value = signal('');
  isInvalid = signal(false);

  onChange(value: string): void {
    this.value.set(value);
    this.isInvalid.set(false);
  }

  onSubmit(event: Event): void {
    event.preventDefault();
    const form = event.target as HTMLFormElement;
    const code = new FormData(form).get('code');

    if (code !== '123456') {
      this.isInvalid.set(true);
      return;
    }

    this.isInvalid.set(false);
    this.value.set('');
    alert('Code verified successfully!');
  }
}

Styling

Passing Tailwind CSS classes

Pass utility classes on the root, groups, slots, and separator. Use container-class for the inner layout container.

import { Component } from '@angular/core';
import {
  AvInputOtpImports,
  AvLabelComponent,
} from '@avesra/angular';

@Component({
  selector: 'app-input-otp-custom-styling-demo',
  imports: [
    AvInputOtpImports,
    AvLabelComponent,
  ],
  host: { class: 'flex w-full max-w-[320px] flex-col gap-2' },
  template: `<div class="flex flex-col gap-2">
  <label av-label>Enter verification code</label>
  <div av-input-otp class="gap-3" container-class="gap-4" [maxLength]="6">
    <div av-input-otp-group class="gap-3">
      <div av-input-otp-slot class="size-12 rounded-lg border-2 text-lg font-bold" [index]="0"></div>
      <div av-input-otp-slot class="size-12 rounded-lg border-2 text-lg font-bold" [index]="1"></div>
      <div av-input-otp-slot class="size-12 rounded-lg border-2 text-lg font-bold" [index]="2"></div>
    </div>
    <div av-input-otp-separator class="bg-border h-1 w-2 rounded-full"></div>
    <div av-input-otp-group class="gap-3">
      <div av-input-otp-slot class="size-12 rounded-lg border-2 text-lg font-bold" [index]="3"></div>
      <div av-input-otp-slot class="size-12 rounded-lg border-2 text-lg font-bold" [index]="4"></div>
      <div av-input-otp-slot class="size-12 rounded-lg border-2 text-lg font-bold" [index]="5"></div>
    </div>
  </div>
</div>`,
})
export class InputOtpCustomStylingDemo {}

Customizing the component classes

To customize the Input OTP classes, use the @layer components directive. Learn more.

@layer components {
  .av-input-otp {
    @apply gap-3;
  }

  .av-input-otp__slot {
    @apply size-12 rounded-xl border-2 font-bold;
  }

  .av-input-otp__slot[data-active="true"] {
    @apply border-accent ring-2 ring-accent/20;
  }

  .av-input-otp__separator {
    @apply h-1 w-2 rounded-full bg-border;
  }
}

Avesra follows the BEM methodology so component variants and states stay reusable and easy to customize.

CSS Classes

The Input OTP component uses these CSS classes:

Base Classes

  • .av-input-otp — Base container
  • .av-input-otp__container — Inner slot layout container
  • .av-input-otp__group — Group of slots
  • .av-input-otp__slot — Individual input slot
  • .av-input-otp__slot-value — The character inside a slot
  • .av-input-otp__caret — Blinking caret indicator
  • .av-input-otp__separator — Visual separator between groups
  • .av-input-otp__input — Hidden native input overlay

State Classes

  • .av-input-otp__slot[data-active="true"] — Currently active slot
  • .av-input-otp__slot[data-filled="true"] — Slot with a character
  • .av-input-otp__slot[data-disabled="true"] — Disabled slot
  • .av-input-otp__slot[data-invalid="true"] — Invalid slot
  • .av-input-otp[data-disabled="true"] — Disabled root

Interactive States

The component supports both CSS pseudo-classes and data attributes:

  • Hover::hover or [data-hovered="true"] on slot
  • Active:[data-active="true"] on slot (currently focused)
  • Filled:[data-filled="true"] on slot (contains a character)
  • Disabled:[data-disabled="true"] on root and slots
  • Invalid:[data-invalid="true"] on slots

API

Root props for div[av-input-otp]. Group, slot, and separator are composed as sibling attribute hosts. Slots require a zero-based [index]. Compose with av-label, av-description, and av-field-error for field chrome.

PropTypeDefaultDescription
maxLengthnumber6Number of input slots / maximum characters.
valuestring''Current OTP value. Supports two-way binding with [(value)].
default-valuestring''Initial value for uncontrolled usage.
completeOutputEmitterRef<string>—Emits when all slots are filled.
variant'primary' | 'secondary''primary'Visual variant. primary has shadow; secondary is lower emphasis for surfaces.
disabledbooleanfalseWhether the input is disabled.
invalidbooleanfalseWhether the input is in an invalid state.
validationErrorsstring[][]Server-side or custom validation error messages.
patternstring—Regex pattern for allowed characters (e.g. AV_REGEXP_ONLY_DIGITS).
inputmode'numeric' | 'text' | 'decimal' | 'tel' | 'search' | 'email' | 'url''numeric'Virtual keyboard type on mobile devices.
placeholderstring—Placeholder character shown in empty active slots.
textAlign'left' | 'center' | 'right''left'Caret alignment — affects slot click focus position.
namestring—Name attribute for form submission.
autofocusbooleanfalseWhether to focus the input on mount.
aria-describedbystring—ID of the element that describes the input (e.g. field error).
container-classstring—Additional CSS classes for the inner container.
input-classstring—Additional CSS classes for the hidden input.
pasteTransformer(text: string) => string—Transform pasted text before filtering (e.g. strip hyphens).

Exported Patterns

Avesra exports common regex patterns for the pattern input:

import {
  AV_REGEXP_ONLY_DIGITS,
  AV_REGEXP_ONLY_CHARS,
  AV_REGEXP_ONLY_DIGITS_AND_CHARS,
} from '@avesra/angular';

@Component({
  // ...
  template: `
    <div av-input-otp [pattern]="regexpOnlyDigits" [maxLength]="6">
      <!-- ... -->
    </div>
  `,
})
export class Example {
  readonly regexpOnlyDigits = AV_REGEXP_ONLY_DIGITS;
}
  • AV_REGEXP_ONLY_DIGITS — Only numeric characters (0-9)
  • AV_REGEXP_ONLY_CHARS — Only alphabetic characters (a-z, A-Z)
  • AV_REGEXP_ONLY_DIGITS_AND_CHARS — Alphanumeric characters (0-9, a-z, A-Z)

Made with ❤ by SyntaxHertz. Open source and available on GitHub.