forty-cdk
llms.txt

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.

forty-cdk/otp-input

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

PropertyTypeDescription
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 attributeValues
data-completepresent / absent
data-disabledpresent / absent
data-readonlypresent / 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, not pattern? FormUiControl.pattern is reserved by Signal Forms for an array of validation patterns the [formField] directive binds in. Reusing the name would break the FormValueControl contract and let the field overwrite your character filter, so the custom char-class RegExp is allowedPattern.

Slot (ForOtpInputSlot)

PropertyTypeDescription
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 attributeValues
data-activepresent / absent (current caret slot)
data-emptypresent / 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 the ariaLabel; the single <input> inside it is the focusable control, and it reflects the same ariaLabel as its own aria-label whenever no field-provided aria-labelledby applies (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 is aria-hidden="true", so neither its character nor the mask bullet is read alongside the input; a slot you render without [forOtpInputSlot] needs the same attribute.
  • mask makes the input a password field. While mask is on, the injected input is type="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 to type="text" when mask turns off; maxlength, inputmode and autocomplete apply either way. Browsers and password managers may offer to fill or save a password in a password field. Leave oneTimeCode on: autocomplete="one-time-code" tells them the field holds a one-time code rather than a password, whereas several browsers ignore autocomplete="off" on a password field.
  • Mobile autofill & keypad. autocomplete="one-time-code" (toggle with oneTimeCode) drives SMS autofill; inputmode is numeric for type="numeric" (plus a legacy pattern="[0-9]*" for older iOS), text otherwise.
  • Character filtering happens live. Rejected characters (per type / allowedPattern) are dropped before they reach the value and fire (reject). Paste runs through pasteTransformer, is filtered, and sliced to length. 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 on prefers-reduced-motion. There is no JS-driven blink.
  • Disabled reflects through one channel. The native disabled attribute already exposes the unavailable state through HTML-AAM, so no aria-disabled is emitted alongside it. Style the disabled state with :disabled or [data-disabled].
  • Falsy state styling selects on absence. aria-readonly / aria-required / aria-invalid / aria-busy are emitted only when truthy. Style the off state with :not([aria-invalid]), never [aria-invalid="false"].
  • @angular/forms is an optional peer. The directive runs fine on a plain [(value)] binding; the only @angular/forms/signals reference 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.