forty-cdk
llms.txt

Primitives

Stepper

A multi-step wizard built on the Tabs pattern: a step list with indicators and separators, a content panel per step, Next / Previous navigation, linear gating with optional Signal Forms completion, a display-only progress mode and an optional progress bar.

forty-cdk/stepper WAI-ARIA APG

Walk the steps with their triggers or the arrow keys. Each step carries data-state for pending, active, completed or error, and the root says which data-mode it is in.

Where should we send your order? Enter a delivery address.

A headless, accessible primitive: mode="interactive" gives full roving tabindex and role="tablist", while mode="progress" renders a progress list with aria-current="step".

See Styling forty-cdk for theming guidance.


Anatomy

<div forStepper [(selectedIndex)]="step" [linear]="true">
  <ol forStepperList ariaLabel="Checkout">
    <li forStepperItem [completed]="step() > 0">
      <button forStepperTrigger>
        <span forStepperIndicator></span>
        Shipping
      </button>
      <span forStepperSeparator></span>
    </li>
    <li forStepperItem>
      <button forStepperTrigger>
        <span forStepperIndicator></span>
        Review
      </button>
    </li>
  </ol>

  <section forStepperContent>Shipping form</section>
  <section forStepperContent>Order review</section>

  <!-- terminal panel, shown once selectedIndex === count -->
  <section forStepperCompletedContent>All steps complete</section>

  <button forStepperPrevious>Back</button>
  <button forStepperNext>Next</button>
</div>

[forStepperProgress] is an optional role="progressbar" part you can place inside the root for a styleable fill.


Examples

Linear wizard with Signal Forms

Each step binds a Signal Forms field. A step is completed when its field is valid and touched, and shows the error state when touched and invalid. No manual [completed] wiring is needed. In [linear] mode Next stays disabled until the current step's field is valid, so fill the input and blur it to advance.

Progress mode + progress bar

A display-only status tracker: the list renders as a plain ordered list with aria-current="step" on the active stage and no roving tabindex or tab roles. The optional forStepperProgress part adds a role="progressbar" that publishes a --for-stepper-progress (0–1) custom property for the fill.

  1. Placed
  2. Processing
  3. Shipped
  4. Delivered

API

ForStepper

PropertyTypeDescription
selectedIndex
model
Two-way bindable selected step index, range 0 … count (the terminal === count is the completed state).
Default: 0
linear
input
Gate forward navigation until preceding steps complete.
Default: false
mode
input
Accessibility model.
Default: 'interactive'
orientation
input
Layout axis; affects arrow-key semantics.
Default: 'horizontal'
activationMode
input
Whether arrow nav also selects (scope-injectable).
Default: 'manual'
loop
input
Wrap arrow navigation (scope-injectable).
Default: true
disabled
input
Disables all triggers and navigation.
Default: false
dir
input
Writing direction (inherits ambient when unset).
Default: null
complete
output()
Output. Fires once each time the stepper enters the completed state.
Default: —

ForStepper exposes two members for the terminal completed state:

  • isCompleted (Signal<boolean>): true when selectedIndex() has reached count() (one past the last step). Read it via a #stepper="forStepper" template reference.
  • (complete): output that fires once each time the stepper enters the completed state. Retreating via [forStepperPrevious] and re-entering emits again.

ForStepperItem

PropertyTypeDescription
completed
input
Marks the step done (manual; wins over field).
Default: false
optional
input
Marks the step skippable in linear mode.
Default: false
disabled
input
Disables only this step.
Default: false
hasError
input
Emits 'error' resolved state when not current (manual; wins over field).
Default: false
field
input
Optional Signal Forms field; drives completed/hasError from validity.
Default: null
state
input
Custom state override that wins over derived state.
Default: null

ForStepperContent

PropertyTypeDescription
step
input
Index of the step this panel belongs to. Unset pairs by DOM-order position.
Default: null

Data attributes

data-state vocabulary

PieceValues
[forStepperItem]pending active completed error <custom>
[forStepperTrigger]same as item
[forStepperIndicator]same as item
[forStepperContent]active inactive
[forStepperCompletedContent]active inactive
[forStepperSeparator]completed pending

Boolean data-*

AttributeWhen present
data-disabledRoot or step is disabled
data-orientationAlways: horizontal or vertical
data-modeAlways (root only): interactive or progress

Interactive mode with linear progression

<div forStepper [(selectedIndex)]="step" [linear]="true">
  <!-- … list / content / Next / Previous … -->
</div>

[linear] gates forward movement on completion: [forStepperNext] advances only while the current step is [completed] or [optional], and a trigger further ahead is selectable only once every step before it is one of the two. Going back is never gated: [forStepperPrevious] and the earlier triggers stay live throughout.

Completed-all content

<div forStepper [(selectedIndex)]="step" (complete)="onDone()">
  <!-- … list / content … -->
  @if (step() >= steps.length) {
  <section forStepperCompletedContent>All steps complete 🎉</section>
  }
</div>

When Next is pressed on the last step, selectedIndex advances to count (one past the last step) and (complete) fires once. [forStepperPrevious] returns to the last step. While completed, every [forStepperContent] panel is inactive and only [forStepperCompletedContent] carries data-state="active" (the others reflect inert + aria-hidden).

Conditionally rendered or reordered panels ([step])

A [forStepperContent] panel pairs with a step by DOM-order position: the Nth panel is the Nth step. When panels are conditionally rendered (or declared in an order that doesn't match the steps), that position no longer identifies the step, so bind [step] to make the pairing explicit. It keeps data-state / inert / aria-labelledby on the panel and aria-controls on the trigger correct no matter which panels are mounted.

<div forStepper [(selectedIndex)]="step">
  <!-- … list … -->
  @for (s of steps; track $index) { @if (step() === $index) {
  <section forStepperContent [step]="$index">{{ s.body }}</section>
  } }
</div>

A structurally hidden [forStepperTrigger] needs no equivalent opt-in: a trigger always resolves its step from its enclosing [forStepperItem].

Signal Forms field-driven completion

Bind a step to a Signal Forms field and its completion and error state follow the field's validity automatically, with no manual [completed] wiring. A step is completed when its field is valid and touched; it reflects error when the field is touched and invalid. A manual [completed] / [hasError] input always wins when set.

import { Component, signal } from '@angular/core';
import { form, required, email } from '@angular/forms/signals';
import {
  ForStepper,
  ForStepperContent,
  ForStepperItem,
  ForStepperList,
  ForStepperTrigger,
} from 'forty-cdk/stepper';

@Component({
  imports: [ForStepper, ForStepperList, ForStepperItem, ForStepperTrigger, ForStepperContent],
  template: `
    <div forStepper [(selectedIndex)]="step" [linear]="true">
      <ol forStepperList ariaLabel="Sign up">
        <li forStepperItem [field]="signup.account">
          <button forStepperTrigger>Account</button>
        </li>
        <li forStepperItem [field]="signup.profile">
          <button forStepperTrigger>Profile</button>
        </li>
      </ol>
      <section forStepperContent>…</section>
      <section forStepperContent>…</section>
    </div>
  `,
})
export class SignupWizard {
  protected readonly step = signal(0);
  private readonly model = signal({ account: '', profile: '' });
  protected readonly signup = form(this.model, (s) => {
    required(s.account);
    email(s.account);
    required(s.profile);
  });
}

Progress mode (display-only)

<div forStepper [selectedIndex]="currentStep" mode="progress">
  <ol forStepperList ariaLabel="Order status">
    <li forStepperItem [completed]="currentStep > 0">
      <span forStepperTrigger>Processing</span>
      <span forStepperSeparator></span>
    </li>
    <li forStepperItem [completed]="currentStep > 1">
      <span forStepperTrigger>Shipped</span>
      <span forStepperSeparator></span>
    </li>
    <li forStepperItem>
      <span forStepperTrigger>Delivered</span>
    </li>
  </ol>
</div>

Progress bar (ForStepperProgress)

An optional role="progressbar" reflecting how far through the steps the user is. Reports aria-valuenow (0–100) + aria-valuetext, and publishes a --for-stepper-progress (0–1) custom property for a styleable fill. valueBy="index" (default) tracks the current step index; valueBy="completed" tracks the count of completed steps.

<div forStepper [(selectedIndex)]="step">
  <div forStepperProgress ariaLabel="Checkout progress"></div>
  <!-- … list / content … -->
</div>
[forStepperProgress]::after {
  content: '';
  display: block;
  width: calc(var(--for-stepper-progress) * 100%);
}

The aria-valuetext string ("Step N of M" on the index basis, "P% complete" on the completed basis) is verbalized by screen readers, so it is localizable centrally via provideForStepperDefaults. Override the stepValueText / progressValueText builders:

providers: [
  provideForStepperDefaults({
    stepValueText: (current, total) => `Paso ${current} de ${total}`,
    progressValueText: (percent) => `${percent}% completado`,
  }),
];

Custom icon per state (indicator example)

<li forStepperItem #step="forStepperItem">
  <button forStepperTrigger>
    <span forStepperIndicator>
      @if (step.resolvedState() === 'completed') {
      <svg aria-hidden="true"><!-- checkmark --></svg>
      } @else if (step.resolvedState() === 'error') {
      <svg aria-hidden="true"><!-- exclamation --></svg>
      } @else { {{ step.index() + 1 }} }
    </span>
    Step label
  </button>
</li>

Or purely via CSS:

[forStepperIndicator][data-state='completed']::before {
  content: '✓';
}
[forStepperIndicator][data-state='error']::before {
  content: '!';
}
[forStepperIndicator][data-state='active']::before {
  content: '●';
}
[forStepperIndicator][data-state='pending']::before {
  content: '○';
}

Known limitations

The panel's focusable-content detection does not re-measure across a shadow boundary, nor on a CSS-only visibility flip. In mode="interactive" the measurement runs on the panel's first render and again on mutations of its own subtree, filtered to the attributes that change whether an element is focusable (disabled, hidden, inert, tabindex, type, contenteditable). Two changes are therefore invisible to it and leave the previous answer standing:

  • Focusable content appearing (or disappearing) inside a shadow root: a web component in the panel that renders its controls on a later tick, or swaps them. The shadow root's own subtree is not observable, so a panel that gains its first focusable control that way keeps its redundant tabindex="0", and one that loses its last keeps none, leaving the panel unreachable by keyboard for a screen-reader user reading it. Nothing in the DOM looks wrong.
  • A visibility flip driven purely by a stylesheet: the measurement excludes CSS-hidden elements, but class and style are not watched, so toggling a class that hides or reveals the panel's only control does not re-measure.

Workaround. Render the panel's focusable content in the light tree, or remount the panel with @if when its content changes, so that a fresh directive instance measures again. Stepper exposes no override input for the detection; ForTabsContent, which shares the mechanism, has [interactiveContent] for it.

The library-wide shadow-DOM statement, covering the two limits that affect overlays rather than panels, is Shadow DOM in forty-cdk/shared.

Keyboard

KeyAction
ArrowRight / ArrowDownMove focus to the next trigger
ArrowLeft / ArrowUpMove focus to the previous trigger
HomeMove focus to the first trigger
EndMove focus to the last trigger
Space / EnterActivate focused trigger (manual mode)
TabMove focus into / out of the step panel

In activationMode="automatic" arrow keys move focus AND select. In activationMode="manual" (default) only Space / Enter activate.

In orientation="vertical" ArrowUp/Down navigate; ArrowLeft/Right are ignored. In orientation="horizontal" ArrowLeft/Right navigate; ArrowUp/Down are ignored. RTL inverts ArrowLeft and ArrowRight.

Accessibility

Implements the WAI-ARIA Tabs pattern.

  • Interactive mode implements the WAI-ARIA Tabs pattern. Each trigger carries role="tab", the list carries role="tablist", and content panels carry role="tabpanel". Each <li forStepperItem> carries role="presentation" so the tablist owns the tab triggers directly. An interposed implicit listitem would violate the tablist's required-owned-elements contract. aria-selected is always emitted, and aria-controls is emitted on every trigger whose panel is registered, so a panel kept mounted while inactive is referenced and one unmounted with @if is not. The trigger ↔ panel pairing resolves each side by its step index: a trigger through its [forStepperItem], a panel through [step] (or its position when unbound). Hiding one trigger or panel with @if therefore never shifts the pairing of the others.
  • Progress mode uses a standard <ol role="list"> with aria-current="step" on the active trigger; each <li forStepperItem> keeps its implicit listitem role. No tab-stop manipulation is performed; triggers carry no role.
  • Disabled triggers in interactive mode reflect aria-disabled="true" + data-disabled="" rather than the native disabled attribute. They leave the Tab sequence but stay reachable with the arrow keys and Home / End, so assistive technology can announce them; activating one does nothing.
  • Linear mode reflects unreachable ahead-steps as aria-disabled="true" on the trigger. data-disabled stays reserved for an explicit disabled, so style an unreachable step off [aria-disabled]. Like a disabled trigger, an unreachable one is reached by arrow navigation but not activated, and activationMode="automatic" does not select it.
  • RTL is supported: set dir="rtl" on the root or a DOM ancestor.
  • Progress bar ([forStepperProgress]) is an opt-in part. When present it exposes role="progressbar" with aria-valuemin="0", aria-valuemax="100", and aria-valuenow derived from the current step or the count of completed steps.
  • Panel tabindex follows the Tabs pattern in mode="interactive": a [forStepperContent] with no focusable descendants is itself a tab stop (tabindex="0") so screen-reader users can focus and read it, while a panel that already contains focusable content is not. The directive detects this and re-measures on subtree changes; two kinds of change are outside what it can observe (see Known limitations). In mode="progress" no tabindex is emitted at all.

Styling

forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-state vocabulary and boolean data-* attributes listed under Data attributes, not off the for* selectors (Styling forty-cdk explains why).

Wrapping in a design system

Subclass the root and re-provide FOR_STEPPER_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.