Form
Input OTP
Usage#
import { WDS_INPUT_OTP } from '@wds/angular/input-otp';Examples#
Default#
import { Component } from '@angular/core';
import { WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSlotComponent } from '@wds/angular/input-otp';
@Component({
selector: 'app-input-otp-default-demo',
imports: [WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSlotComponent],
template: `
<wds-input-otp maxlength="6" aria-label="One-time code">
<wds-input-otp-group>
<wds-input-otp-slot></wds-input-otp-slot>
<wds-input-otp-slot></wds-input-otp-slot>
<wds-input-otp-slot></wds-input-otp-slot>
<wds-input-otp-slot></wds-input-otp-slot>
<wds-input-otp-slot></wds-input-otp-slot>
<wds-input-otp-slot></wds-input-otp-slot>
</wds-input-otp-group>
</wds-input-otp>
`,
})
export class InputOtpDefaultDemo {}Separator#
import { Component } from '@angular/core';
import { WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSeparatorComponent, WdsInputOtpSlotComponent } from '@wds/angular/input-otp';
@Component({
selector: 'app-input-otp-separator-demo',
imports: [WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSlotComponent, WdsInputOtpSeparatorComponent],
template: `
<wds-input-otp maxlength="6" aria-label="One-time code">
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
<wds-input-otp-separator></wds-input-otp-separator>
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
<wds-input-otp-separator></wds-input-otp-separator>
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
</wds-input-otp>
`,
})
export class InputOtpSeparatorDemo {}Four digits#
maxlength sets the length of the code.
import { Component } from '@angular/core';
import { WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSlotComponent } from '@wds/angular/input-otp';
@Component({
selector: 'app-input-otp-four-digits-demo',
imports: [WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSlotComponent],
template: `
<wds-input-otp maxlength="4" aria-label="PIN">
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
</wds-input-otp>
`,
})
export class InputOtpFourDigitsDemo {}Alphanumeric#
pattern is the regular expression of one character; inputmode="text" shows a letter keyboard on phones.
import { Component } from '@angular/core';
import { WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSeparatorComponent, WdsInputOtpSlotComponent } from '@wds/angular/input-otp';
@Component({
selector: 'app-input-otp-alphanumeric-demo',
imports: [WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSlotComponent, WdsInputOtpSeparatorComponent],
template: `
<wds-input-otp maxlength="6" pattern="[a-zA-Z0-9]" inputmode="text" aria-label="Recovery code">
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
<wds-input-otp-separator></wds-input-otp-separator>
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
</wds-input-otp>
`,
})
export class InputOtpAlphanumericDemo {}Pattern#
pattern (default [0-9]) is not typed. @wds/core/input-otp exports REGEXP_ONLY_DIGITS, REGEXP_ONLY_CHARS and REGEXP_ONLY_DIGITS_AND_CHARS.
import { Component } from '@angular/core';
import { WdsFieldComponent, WdsFieldLabelComponent } from '@wds/angular/field';
import { WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSlotComponent } from '@wds/angular/input-otp';
@Component({
selector: 'app-input-otp-pattern-demo',
imports: [WdsFieldComponent, WdsFieldLabelComponent, WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSlotComponent],
template: `
<wds-field class="w-fit">
<wds-field-label>Digits only</wds-field-label>
<wds-input-otp maxlength="6" pattern="[0-9]">
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
</wds-input-otp>
</wds-field>
`,
})
export class InputOtpPatternDemo {}Controlled#
wds-input reports every change; wds-complete fires when every slot is filled.
Enter your one-time password.
import { Component, signal } from '@angular/core';
import { FormField, form } from '@angular/forms/signals';
import { WdsInputOtpField } from '@wds/angular/forms';
import { WDS_INPUT_OTP } from '@wds/angular/input-otp';
@Component({
selector: 'app-input-otp-controlled-demo',
imports: [...WDS_INPUT_OTP, FormField, WdsInputOtpField],
template: `
<div class="flex flex-col items-center gap-2">
<wds-input-otp maxlength="6" aria-label="One-time code" [formField]="otpForm.code">
<wds-input-otp-group>
<wds-input-otp-slot /><wds-input-otp-slot /><wds-input-otp-slot />
<wds-input-otp-slot /><wds-input-otp-slot /><wds-input-otp-slot />
</wds-input-otp-group>
</wds-input-otp>
<p class="m-0 text-center text-sm">
@if (otp().code) {
You entered: {{ otp().code }}
} @else {
Enter your one-time password.
}
</p>
</div>
`,
})
export class InputOtpControlledDemo {
protected readonly otp = signal({ code: '' });
protected readonly otpForm = form(this.otp);
}Disabled#
import { Component } from '@angular/core';
import { WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSeparatorComponent, WdsInputOtpSlotComponent } from '@wds/angular/input-otp';
@Component({
selector: 'app-input-otp-disabled-demo',
imports: [WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSlotComponent, WdsInputOtpSeparatorComponent],
template: `
<wds-input-otp maxlength="6" value="123456" disabled aria-label="One-time code">
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
<wds-input-otp-separator></wds-input-otp-separator>
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
</wds-input-otp>
`,
})
export class InputOtpDisabledDemo {}Invalid#
invalid on wds-input-otp marks the whole code; invalid on a wds-input-otp-slot marks one slot.
import { Component } from '@angular/core';
import { WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSeparatorComponent, WdsInputOtpSlotComponent } from '@wds/angular/input-otp';
@Component({
selector: 'app-input-otp-invalid-demo',
imports: [WdsInputOtpComponent, WdsInputOtpGroupComponent, WdsInputOtpSlotComponent, WdsInputOtpSeparatorComponent],
template: `
<wds-input-otp maxlength="6" value="000000" invalid aria-label="One-time code">
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
<wds-input-otp-separator></wds-input-otp-separator>
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
<wds-input-otp-separator></wds-input-otp-separator>
<wds-input-otp-group><wds-input-otp-slot></wds-input-otp-slot><wds-input-otp-slot></wds-input-otp-slot></wds-input-otp-group>
</wds-input-otp>
`,
})
export class InputOtpInvalidDemo {}Forms#
Integration with Angular forms. Pick the API your app uses.
Signal forms#
WdsInputOtpField connects the code to [formField]. minLength() checks that every digit is entered.
import { Component, computed, signal } from '@angular/core';
import { FormField, FormRoot, form, minLength, required } from '@angular/forms/signals';
import { WdsButtonComponent } from '@wds/angular/button';
import { WDS_FIELD } from '@wds/angular/field';
import { WdsInputOtpField } from '@wds/angular/forms';
import { WDS_INPUT_OTP } from '@wds/angular/input-otp';
@Component({
selector: 'app-input-otp-signal-forms-demo',
imports: [...WDS_FIELD, ...WDS_INPUT_OTP, FormField, FormRoot, WdsButtonComponent, WdsInputOtpField],
template: `
<form class="flex w-sm max-w-full flex-col gap-4" [formRoot]="verifyForm">
<wds-field [invalid]="showErrors()">
<wds-field-label>Verification code</wds-field-label>
<wds-input-otp maxlength="6" [formField]="verifyForm.code">
<wds-input-otp-group>
<wds-input-otp-slot /><wds-input-otp-slot /><wds-input-otp-slot />
<wds-input-otp-slot /><wds-input-otp-slot /><wds-input-otp-slot />
</wds-input-otp-group>
</wds-input-otp>
<wds-field-description>Enter the 6-digit code sent to your email.</wds-field-description>
<wds-field-error [errors]="showErrors() ? verifyForm.code().errors() : []" />
</wds-field>
<wds-button type="submit" class="self-start">Verify</wds-button>
<p class="m-0 font-mono text-sm text-muted-foreground">Submitted: {{ submitted() }}</p>
</form>
`,
})
export class InputOtpSignalFormsDemo {
protected readonly verify = signal({ code: '' });
protected readonly submitted = signal('—');
protected readonly verifyForm = form(
this.verify,
(path) => {
required(path.code, { message: 'Enter the verification code.' });
minLength(path.code, 6, { message: 'The code has 6 digits.' });
},
{
submission: {
action: async (form) => {
this.submitted.set(JSON.stringify(form().value()));
},
},
}
);
/** Errors appear after the user leaves the field or submits the form. */
protected readonly showErrors = computed(() => this.verifyForm.code().touched() && this.verifyForm.code().invalid());
}Template-driven#
WdsTextValueAccessor makes ngModel work on wds-input-otp, with required and minlength validators.
import { Component, signal } from '@angular/core';
import { FormsModule, type NgForm } from '@angular/forms';
import { WdsButtonComponent } from '@wds/angular/button';
import { WDS_FIELD } from '@wds/angular/field';
import { WdsTextValueAccessor } from '@wds/angular/forms';
import { WDS_INPUT_OTP } from '@wds/angular/input-otp';
@Component({
selector: 'app-input-otp-template-driven-demo',
imports: [...WDS_FIELD, ...WDS_INPUT_OTP, FormsModule, WdsButtonComponent, WdsTextValueAccessor],
template: `
<form #verifyForm="ngForm" class="flex w-sm max-w-full flex-col gap-4" (ngSubmit)="save(verifyForm)">
<wds-field [invalid]="!!code.invalid && !!code.touched">
<wds-field-label>Verification code</wds-field-label>
<wds-input-otp #code="ngModel" name="code" maxlength="6" [(ngModel)]="verify.code" required minlength="6">
<wds-input-otp-group>
<wds-input-otp-slot /><wds-input-otp-slot /><wds-input-otp-slot />
<wds-input-otp-slot /><wds-input-otp-slot /><wds-input-otp-slot />
</wds-input-otp-group>
</wds-input-otp>
<wds-field-description>Enter the 6-digit code sent to your email.</wds-field-description>
@if (code.invalid && code.touched) {
<wds-field-error>{{ code.hasError('required') ? 'Enter the verification code.' : 'The code has 6 digits.' }}</wds-field-error>
}
</wds-field>
<wds-button type="submit" class="self-start">Verify</wds-button>
<p class="m-0 font-mono text-sm text-muted-foreground">Submitted: {{ submitted() }}</p>
</form>
`,
})
export class InputOtpTemplateDrivenDemo {
protected readonly verify = { code: '' };
protected readonly submitted = signal('—');
protected save(form: NgForm): void {
if (form.invalid) {
form.form.markAllAsTouched();
return;
}
this.submitted.set(JSON.stringify(form.value));
}
}Reactive forms#
WdsTextValueAccessor works with formControl and formControlName.
import { Component, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { WdsButtonComponent } from '@wds/angular/button';
import { WDS_FIELD } from '@wds/angular/field';
import { WdsTextValueAccessor } from '@wds/angular/forms';
import { WDS_INPUT_OTP } from '@wds/angular/input-otp';
@Component({
selector: 'app-input-otp-reactive-forms-demo',
imports: [...WDS_FIELD, ...WDS_INPUT_OTP, ReactiveFormsModule, WdsButtonComponent, WdsTextValueAccessor],
template: `
<form class="flex w-sm max-w-full flex-col gap-4" [formGroup]="verifyForm" (ngSubmit)="save()">
<wds-field [invalid]="code.invalid && code.touched">
<wds-field-label>Verification code</wds-field-label>
<wds-input-otp maxlength="6" formControlName="code">
<wds-input-otp-group>
<wds-input-otp-slot /><wds-input-otp-slot /><wds-input-otp-slot />
<wds-input-otp-slot /><wds-input-otp-slot /><wds-input-otp-slot />
</wds-input-otp-group>
</wds-input-otp>
<wds-field-description>Enter the 6-digit code sent to your email.</wds-field-description>
@if (code.invalid && code.touched) {
<wds-field-error>{{ code.hasError('required') ? 'Enter the verification code.' : 'The code has 6 digits.' }}</wds-field-error>
}
</wds-field>
<wds-button type="submit" class="self-start">Verify</wds-button>
<p class="m-0 font-mono text-sm text-muted-foreground">Submitted: {{ submitted() }}</p>
</form>
`,
})
export class InputOtpReactiveFormsDemo {
protected readonly verifyForm = new FormGroup({
code: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(6)] }),
});
protected readonly code = this.verifyForm.controls.code;
protected readonly submitted = signal('—');
protected save(): void {
if (this.verifyForm.invalid) {
this.verifyForm.markAllAsTouched();
return;
}
this.submitted.set(JSON.stringify(this.verifyForm.getRawValue()));
}
}API References#
wds-input-otp#
autocomplete="one-time-code"), so code autofill from SMS,
pasting the whole code, Ctrl+A, copying and screen readers work (one field with a label).
wds-input-otp-slot elements only show the characters and the caret. Without them the element renders maxlength slots itself.
In a form it works like <input>: name, required (needs every character), reset.
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
| value | string | '' | |
| name | string | '' | |
| maxlength.maxLength | number | undefined | wds-input-otp-slot elements, or 6. | |
| pattern | REGEXP_ONLY_DIGITS | [0-9] or [a-zA-Z0-9]. | |
| inputmode | 'numeric' | 'text' | 'numeric' | numeric for digits, text for letters. |
| size | WdsInputOtpSize | 'default' | |
| disabled | boolean | false | |
| readonly | boolean | false | |
| required | boolean | false | |
| invalid | boolean | false | aria-invalid. |
Events
| Event | Description |
|---|---|
| wds-inputCustomEvent<WdsTextChangeDetail> | |
| wds-completeCustomEvent<WdsTextChangeDetail> | |
| wds-changeCustomEvent<WdsTextChangeDetail> |
Methods
| Method | Description |
|---|---|
| formResetCallback() | |
| formDisabledCallback(disabled: boolean) | |
| formStateRestoreCallback(state: string | File | FormData | null) | |
| checkValidity() | true when the value is valid; otherwise fires invalid on the element. |
| reportValidity() | checkValidity(), and shows the browser validation bubble when the value is invalid. |
| setCustomValidity(message: string) | input.setCustomValidity(). |
Slots
| Slot | Description |
|---|---|
| (default) | wds-input-otp-group, wds-input-otp-slot, wds-input-otp-separator. |
CSS parts (::part)
| Part | Description |
|---|---|
| input |
wds-input-otp-group#
wds-input-otp-slot elements joined into one block. When a slot has an error,
the whole group gets a red ring.
Slots
| Slot | Description |
|---|---|
| (default) | wds-input-otp-slot elements. |
wds-input-otp-slot#
wds-input-otp. The document order of the slots sets the character position.
A click on a slot puts the caret there. The first and last slot of a group have rounded corners.
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
| invalid | boolean | false | invalid on wds-input-otp marks the whole code). |
CSS parts (::part)
| Part | Description |
|---|---|
| slot |
wds-input-otp-separator#
Accessibility#
Screen reader#
autocomplete="one-time-code"), not one field per character. The slots and separators are hidden from screen readers.
wds-field-label in wds-field, or aria-label on the element. Without it the field gets a default name with the code length (currently in Polish, Kod jednorazowy, 6 znaków).
invalid sets aria-invalid="true"; disabled, readonly and required are set on the native field. In a form the code is submitted under name.
Keyboard support#
| Key | Function |
|---|---|
⌘ + A), e.g. to copy or replace it. | |
pattern are skipped. |