DEV Community

Cover image for Announcing: input-otp: One time passcode Input.
Dharmen Shah
Dharmen Shah

Posted on

Announcing: input-otp: One time passcode Input.

The only accessible & unstyled & full featured Input OTP component for Angular

OTP Input for Angular 🔐 by @shhdharmen

Inspired from guilhermerodz/input-otp

Online Demo

Image description

Install

ng add @ngxpert/input-otp
Enter fullscreen mode Exit fullscreen mode

Usage

Import the component.

import { InputOTPComponent } from '@ngxpert/input-otp';
@Component({
  selector: 'app-my-component',
  template: `
    <input-otp [maxLength]="6" [(ngModel)]="otpValue" #otpInput>
      <div style="display: flex;">
        @for (slot of otpInput.slots(); track $index) {
          <div>{{ slot.char }}</div>
        }
      </div>
    </input-otp>
  `,
  imports: [InputOTPComponent, FormsModule],
})
export class MyComponent {
  otpValue = '';
}
Enter fullscreen mode Exit fullscreen mode

Features

  • ✅ Works with Template-Driven Forms and Reactive Forms out of the box.
  • ✅ Supports copy-paste-cut
  • ✅ Supports all keybindings

Default example

The example below uses tailwindcss tailwind-merge clsx. You can see it online here, code available here.

main.component

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { InputOTPComponent } from '@ngxpert/input-otp';
import { SlotComponent } from './slot.component';
import { FakeDashComponent } from './fake-components';

@Component({
  selector: 'app-examples-main',
  template: `
    <input-otp
      [maxLength]="6"
      containerClass="group flex items-center has-[:disabled]:opacity-30"
      [(ngModel)]="otpValue"
      #otp="inputOtp"
    >
      <div class="flex">
        @for (
          slot of otp.slots().slice(0, 3);
          track $index;
          let first = $first;
          let last = $last
        ) {
          <app-slot
            [isActive]="slot.isActive"
            [char]="slot.char"
            [placeholderChar]="slot.placeholderChar"
            [hasFakeCaret]="slot.hasFakeCaret"
            [first]="first"
            [last]="last"
          />
        }
      </div>
      <app-fake-dash />
      <div class="flex">
        @for (
          slot of otp.slots().slice(3, 6);
          track $index + 3;
          let last = $last;
          let first = $first
        ) {
          <app-slot
            [isActive]="slot.isActive"
            [char]="slot.char"
            [placeholderChar]="slot.placeholderChar"
            [hasFakeCaret]="slot.hasFakeCaret"
            [first]="first"
            [last]="last"
          />
        }
      </div>
    </input-otp>
  `,
  imports: [FormsModule, InputOTPComponent, SlotComponent, FakeDashComponent],
})
export class ExamplesMainComponent {
  otpValue = '';
}

Enter fullscreen mode Exit fullscreen mode

slot.component

import { Component, Input } from '@angular/core';
import { FakeCaretComponent } from './fake-components';
import { cn } from './utils';

@Component({
  selector: 'app-slot',
  template: `
    <div
      [class]="
        cn(
          'relative w-10 h-14 text-[2rem]',
          'flex items-center justify-center',
          'transition-all duration-300',
          'border-y border-r',
          'group-hover:border-accent-foreground/20 group-focus-within:border-accent-foreground/20',
          'outline outline-0 outline-accent-foreground/20',
          { 'outline-4 outline-accent-foreground': isActive },
          { 'border-l rounded-l-md': first },
          { 'rounded-r-md': last }
        )
      "
    >
      @if (char) {
        <div>{{ char }}</div>
      } @else {
        {{ ' ' }}
      }
      @if (hasFakeCaret) {
        <app-fake-caret />
      }
    </div>
  `,
  imports: [FakeCaretComponent],
})
export class SlotComponent {
  @Input() isActive = false;
  @Input() char: string | null = null;
  @Input() placeholderChar: string | null = null;
  @Input() hasFakeCaret = false;
  @Input() first = false;
  @Input() last = false;
  cn = cn;
}

Enter fullscreen mode Exit fullscreen mode

fake-components

import { Component } from '@angular/core';

@Component({
  selector: 'app-fake-dash',
  template: `
    <div class="flex w-10 justify-center items-center">
      <div class="w-3 h-1 rounded-full bg-black/75"></div>
    </div>
  `,
})
export class FakeDashComponent {}

@Component({
  selector: 'app-fake-caret',
  template: `
    <div
      class="absolute pointer-events-none inset-0 flex items-center justify-center animate-caret-blink"
    >
      <div class="w-[2px] h-8 bg-black/75"></div>
    </div>
  `,
})
export class FakeCaretComponent {}

Enter fullscreen mode Exit fullscreen mode

utils

import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

import type { ClassValue } from 'clsx';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Enter fullscreen mode Exit fullscreen mode

styles

@import "tailwindcss";

@theme {
  --animate-caret-blink: caret-blink 1.2s ease-out infinite;
  @keyframes caret-blink {
    0%,
    70%,
    100% {
      opacity: 1;
    }
    20%,
    50% {
      opacity: 0;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

How it works

There's currently no native OTP/2FA/MFA input in HTML, which means people are either going with

  1. a simple input design or
  2. custom designs like this one.

This library works by rendering an invisible input as a sibling of the slots, contained by a relatively positioned parent (the container root called input-otp).

API Reference

<input-otp>

The root container. Define settings for the input via inputs. Then, use the inputOtp.slots() property to create the slots.

Inputs and outputs

export interface InputOTPInputsOutputs {
  // The number of slots
  maxLength: InputSignal<number>;

  // Pro tip: input-otp export some patterns by default such as REGEXP_ONLY_DIGITS which you can import from the same library path
  // Example: import { REGEXP_ONLY_DIGITS } from '@ngxpert/input-otp';
  // Then use it as: <input-otp [pattern]="REGEXP_ONLY_DIGITS">
  pattern?: InputSignal<string | RegExp | undefined>;

  // While rendering the input slot, you can access both the char and the placeholder, if there's one and it's active.
  // If you expect input to be of 6 characters, provide 6 characters in the placeholder.
  placeholder?: InputSignal<string | undefined>;

  // Virtual keyboard appearance on mobile
  // Default: 'numeric'
  inputMode?: InputSignal<'numeric' | 'text'>;

  // The autocomplete attribute for the input
  // Default: 'one-time-code'
  autoComplete?: InputSignal<string | undefined>;

  // The class name for the container
  containerClass?: InputSignal<string | undefined>;

  // Emits the complete value when the input is filled
  complete: OutputEmitterRef<string>;
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)