Primitives
OTP Input
A one-time-code / PIN field on the single-input model: typed and pasted characters fill styled slots, with masking, character filtering and a complete event.
Type or paste a code. Focus advances a slot at a time, the active slot carries data-active, and the group reflects data-complete once it is full.
Headless and styleless. One real <input maxlength=N> carries the whole code as a string, and the [forOtpInputSlot] pieces are a pure styling surface painted over it. There is no WAI-ARIA APG pattern for OTP. This single-input approach gives the cleanest screen-reader experience (one ordinary text field, not "edit text, 1 of 6" announced N times), native mobile SMS autofill via autocomplete="one-time-code", and native paste / caret / selection. It implements Angular's FormValueControl<string> from @angular/forms/signals, so it auto-wires with [formField] and auto-associates inside a [forField] (label, description, and error wiring) with zero extra markup.
How it works
Apply [forOtpInput] on a wrapper element. It becomes a role="group" and the directive injects the single visually-hidden-but-interactive <input> inside it. You style that input to overlay the slots (typically position: absolute; inset: 0 with a transparent or caret-color-only appearance); pointer events land on it and native caret positioning drives which slot is active. Put a class on that input with inputClass, since you never write it in your template. The slots are visual boxes hidden from assistive technology.
The focusable, submittable control is the injected <input>, not the role="group" host. Form-control state, the field association, and native name submission therefore all live on that input.
Anatomy
<div
forOtpInput
[(value)]="code"
[length]="6"
type="numeric"
ariaLabel="Verification code"
#otp="forOtpInput"
>
<!-- one [forOtpInputSlot] per index in otp.slots() -->
<div forOtpInputSlot [index]="i" #s="forOtpInputSlot">
{{ s.char() }}
<!-- rendered only when s.hasFakeCaret() is true -->
<span class="caret"></span>
</div>
</div>
Each [forOtpInputSlot] host carries aria-hidden="true", so the injected input is the only element in the group that exposes the code. If you render a slot without the directive, mark it aria-hidden="true" yourself, or screen readers read its character and then the same code again from the input.
Exported pattern constants
Bind one of OTP_REGEXP_ONLY_DIGITS, OTP_REGEXP_ONLY_CHARS, or OTP_REGEXP_ONLY_DIGITS_AND_CHARS to [allowedPattern] for a custom restriction. allowedCharForType / inputModeForType expose the type → RegExp / inputmode mapping.
Field composition
Drop the OTP inside a [forField] and it auto-associates with the label, description, and error region, with no id / aria-* wiring by hand. The label's for, aria-labelledby, aria-describedby, and aria-errormessage all land on the real input.
import { Component, signal } from '@angular/core';
import { form, required, FormField } from '@angular/forms/signals';
import { ForField, ForFieldError, ForLabel } from 'forty-cdk/field';
import { ForOtpInput, ForOtpInputSlot } from 'forty-cdk/otp-input';
@Component({
selector: 'demo-otp-field',
imports: [ForField, ForLabel, ForFieldError, ForOtpInput, ForOtpInputSlot, FormField],
template: `
<div forField>
<label forLabel>One-time code</label>
<div
forOtpInput
class="otp-input"
[formField]="login.otp"
[length]="6"
type="numeric"
#otp="forOtpInput"
>
@for (i of otp.slots(); track i) {
<div forOtpInputSlot class="otp-input-slot" [index]="i" #s="forOtpInputSlot">
{{ s.char() }}
</div>
}
</div>
@if (err.shown()) {
<p forFieldError #err="forFieldError">{{ err.messages().join(', ') }}</p>
}
</div>
`,
})
export class DemoOtpField {
readonly model = signal({ otp: '' });
readonly login = form(this.model, (l) => {
required(l.otp, { message: 'Enter the 6-digit code' });
});
}
Examples
Masked PIN with paste transform
mask obscures the slots and turns the injected input into a password field while value() stays raw, and a pasteTransformer strips spaces and dashes before filtering, so pasting “12 34 56” fills cleanly. type still rejects anything outside the numeric character class as you type.
API
ForOtpInput
| Property | Type | Description |
|---|---|---|
value | model | Two-way bindable code. Rendered length is clamped to length.Default: — |
length | input.required | Number of characters / slots. Default: — |
type | input | Allowed character class. Ignored when allowedPattern is set.Default: 'numeric' |
allowedPattern | input | Custom allowed-character RegExp (tested per character); overrides type.Default: — |
mask | input | Obscure the rendered char() and make the real input type="password" (PIN entry); value() stays raw.Default: — |
oneTimeCode | input | Toggle autocomplete="one-time-code" for SMS autofill.Default: true |
pasteTransformer | input | Rewrite pasted text before it fills the slots (e.g. strip separators). Default: — |
ariaLabel | input | Accessible name for the group, also reflected onto the real input when no field label applies. Emits aria-label only when truthy.Default: — |
inputClass | input | Class(es) applied to the injected real input, for global or utility styles. null adds no class attribute.Default: — |
disabled / readonly / required / invalid / pending / dirty | input | Shared form-control flags (see Field). Default: — |
name | input | Reflected as the real input's name for native form submission.Default: — |
touched | model | Set to true on blur.Default: — |
complete | output | Output. Fires when every slot is filled, by typing or paste. Default: — |
reject | output | Output. Fires when an entered / pasted character is rejected by type / allowedPattern.Default: — |
| Data attribute | Values | |
|---|---|---|
data-complete | present / absent | |
data-disabled | present / absent | |
data-readonly | present / absent |
The injected real <input> (created inside the [forOtpInput] wrapper) additionally carries data-disabled, data-readonly, data-touched, data-dirty, data-pending, and data-invalid (present / absent), mirroring its form-control flags.
ForOtpInput also exposes a slots() signal (readonly number[]) for the @for, a filled() signal (reflected as data-complete), and a focus() method.
Why
allowedPattern, notpattern?FormUiControl.patternis reserved by Signal Forms for an array of validation patterns the[formField]directive binds in. Reusing the name would break theFormValueControlcontract and let the field overwrite your character filter, so the custom char-class RegExp isallowedPattern.
Slot (ForOtpInputSlot)
| Property | Type | Description |
|---|---|---|
index | input.required | This slot's 0-based position. Default: — |
char() | Signal | The slot's character (masked when mask), or null when empty.Default: — |
active() | Signal | Whether this slot is the active caret position. Default: — |
hasFakeCaret() | Signal | Whether to render a fake caret here (active + empty + focused). Default: — |
| Data attribute | Values | |
|---|---|---|
data-active | present / absent (current caret slot) | |
data-empty | present / absent (no character) |
The slot host also carries a static aria-hidden="true".
Accessibility
- One real text field, not N boxes. The
role="group"wrapper carries theariaLabel; the single<input>inside it is the focusable control, and it reflects the sameariaLabelas its ownaria-labelwhenever no field-providedaria-labelledbyapplies (a[forField]label always wins). Screen readers announce the group name on entry and treat the code as one ordinary, named text field. Every slot isaria-hidden="true", so neither its character nor themaskbullet is read alongside the input; a slot you render without[forOtpInputSlot]needs the same attribute. maskmakes the input a password field. Whilemaskis on, the injected input istype="password", so screen readers announce a password field and do not speak the code as it is typed, and the browser applies its password-field protections. It returns totype="text"whenmaskturns off;maxlength,inputmodeandautocompleteapply either way. Browsers and password managers may offer to fill or save a password in a password field. LeaveoneTimeCodeon:autocomplete="one-time-code"tells them the field holds a one-time code rather than a password, whereas several browsers ignoreautocomplete="off"on a password field.- Mobile autofill & keypad.
autocomplete="one-time-code"(toggle withoneTimeCode) drives SMS autofill;inputmodeisnumericfortype="numeric"(plus a legacypattern="[0-9]*"for older iOS),textotherwise. - Character filtering happens live. Rejected characters (per
type/allowedPattern) are dropped before they reach the value and fire(reject). Paste runs throughpasteTransformer, is filtered, and sliced tolength. A rejected keystroke never moves the insertion point: the caret stays at the position it was being edited at, so typing a disallowed character mid-code leaves the next character landing in the slot the user was on. A paste replaces the whole code and leaves the caret at the end. - Fake caret is yours to style. The slot exposes
hasFakeCaret(); render and animate the blink in CSS, gated onprefers-reduced-motion. There is no JS-driven blink. - Disabled reflects through one channel. The native
disabledattribute already exposes the unavailable state through HTML-AAM, so noaria-disabledis emitted alongside it. Style the disabled state with:disabledor[data-disabled]. - Falsy state styling selects on absence.
aria-readonly/aria-required/aria-invalid/aria-busyare emitted only when truthy. Style the off state with:not([aria-invalid]), never[aria-invalid="false"]. @angular/formsis an optional peer. The directive runs fine on a plain[(value)]binding; the only@angular/forms/signalsreference is a type import, erased at build.
Styling
forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under API, not off the for* selectors (Styling forty-cdk explains why).
.otp-input-slot[data-active] {
outline: 2px solid var(--ring);
}
.otp-input-slot[data-empty] {
color: transparent;
}
The one rule the layout has to keep: make the injected <input> overlay the slots so it stays the interactive surface. Reach it through inputClass (inputClass="otp-control" here). The directive creates that input at runtime, so it never carries your component's encapsulation attribute: put the rule in a global stylesheet, or use utility classes, rather than in component-scoped CSS.
.otp-slot {
position: relative;
width: 2.5rem;
height: 3rem; /* ... */
}
.otp {
position: relative;
display: flex;
gap: 0.5rem;
}
.otp-control {
position: absolute;
inset: 0;
opacity: 0;
}
.otp-caret {
/* style + animate; gate the blink on prefers-reduced-motion */
}
Wrapping in a design system
Wrapping form primitives documents both supported wrapper patterns: hostDirectives with the exported FOR_OTP_INPUT_HOST_DIRECTIVE_INPUTS / FOR_OTP_INPUT_HOST_DIRECTIVE_OUTPUTS name tuples, and subclassing.