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-otppnpm dlx @ndop/cli add input-otpyarn dlx @ndop/cli add input-otpbunx @ndop/cli add input-otpInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/input-otp/input-otp.tsimport { computed, Directive, input } from '@angular/core'; import { NdInputOtp, NdInputOtpGroup, NdInputOtpInput, NdInputOtpSeparator, NdInputOtpSlot, } from '@ndop/primitives/input-otp'; import { cn } from '@/lib/utils'; /** * 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`. * * @example * ```html * <div ndInputOtp [length]="6" [(value)]="code"> * <input ndInputOtpInput aria-label="Verification code" /> * <div ndInputOtpGroup> * @for (index of [0, 1, 2, 3, 4, 5]; track index) { * <div ndInputOtpSlot [index]="index"></div> * } * </div> * </div> * ``` */ @Directive({ selector: '[ndInputOtp]', hostDirectives: [ { directive: NdInputOtp, inputs: [ 'value', 'defaultValue', 'length', 'allow', 'inputMode', 'disabled', 'invalid', ], outputs: ['valueChange', 'completed'], }, ], host: { 'data-slot': 'input-otp', '[class]': 'classes()' }, }) export class InputOtp { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex items-center gap-2 has-disabled:opacity-50', 'relative w-fit', this.class(), ), ); } /** * The native input of an {@link InputOtp}: transparent and stretched over * the slots, so clicking them focuses it. * * @example * ```html * <input ndInputOtpInput aria-label="Verification code" /> * ``` */ @Directive({ selector: 'input[ndInputOtpInput]', hostDirectives: [{ directive: NdInputOtpInput, inputs: ['id'] }], host: { 'data-slot': 'input-otp-input', '[class]': 'classes()' }, }) export class InputOtpInput { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute inset-0 z-20 size-full bg-transparent text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed', this.class(), ), ); } /** * A row of {@link InputOtpSlot}s that join into one box. * * @example * ```html * <div ndInputOtpGroup>…</div> * ``` */ @Directive({ selector: '[ndInputOtpGroup]', hostDirectives: [NdInputOtpGroup], host: { 'data-slot': 'input-otp-group', '[class]': 'classes()' }, }) export class InputOtpGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex items-center', this.class()), ); } /** * One character of an {@link InputOtp}, ringed while it's where the next * one goes, with a blinking caret while that slot is empty. * * @example * ```html * <div ndInputOtpSlot [index]="0"></div> * ``` */ @Directive({ selector: '[ndInputOtpSlot]', hostDirectives: [{ directive: NdInputOtpSlot, inputs: ['index'] }], host: { 'data-slot': 'input-otp-slot', '[class]': 'classes()' }, }) export class InputOtpSlot { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative flex h-9 w-9 items-center justify-center border-y border-e border-input text-sm shadow-xs transition-all outline-none first:rounded-s-md first:border-s last:rounded-e-md data-invalid:border-destructive data-active:z-10 data-active:border-ring data-active:ring-[3px] data-active:ring-ring/50 data-active:data-invalid:border-destructive data-active:data-invalid:ring-destructive/20 dark:bg-input/30 dark:data-active:data-invalid:ring-destructive/40', 'data-caret:after:pointer-events-none data-caret:after:absolute data-caret:after:h-4 data-caret:after:w-px data-caret:after:animate-caret-blink data-caret:after:bg-foreground data-caret:after:duration-1000', this.class(), ), ); } /** * A separator between groups of slots. Put a minus icon inside. * * @example * ```html * <div ndInputOtpSeparator><svg ndIcon="minus"></svg></div> * ``` */ @Directive({ selector: '[ndInputOtpSeparator]', hostDirectives: [NdInputOtpSeparator], host: { 'data-slot': 'input-otp-separator' }, }) export class InputOtpSeparator {}src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
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.
That code isn't right. Check it, or send a new one.
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@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 |
|---|---|
| Backspace | Deletes 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
completedoutput 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]requiredThe 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. | boolean | false |
[invalid]Marks the code invalid when no forms library is bound; with one, the
field's state decides. | boolean | false |
[(value)]The code. Two-way bindable with [(value)]. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | input-otp |
data-complete | — |
data-disabled | — |
data-invalid | — |
InputOtpInput
input[ndInputOtpInput]role nativeThe 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-slot | input-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-slot | input-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]requiredThe character's position in the code, from 0. | number | — |
| Data attribute | Values |
|---|---|
data-slot | input-otp-slot |
data-active | — |
data-filled | — |
data-caret | — |
data-invalid | — |
InputOtpSeparator
[ndInputOtpSeparator]role separatorA separator between groups of slots. Put a minus icon inside.
Manages aria-hidden.
| Data attribute | Values |
|---|---|
data-slot | input-otp-separator |
Agent notes
Do
- Name it with a label, such as "Verification code".
- Submit the code from the
completedoutput, 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.