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.
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.
- Placed
- Processing
- Shipped
- Delivered
API
ForStepper
| Property | Type | Description |
|---|---|---|
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 whenselectedIndex()has reachedcount()(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
| Property | Type | Description |
|---|---|---|
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
| Property | Type | Description |
|---|---|---|
step | input | Index of the step this panel belongs to. Unset pairs by DOM-order position. Default: null |
Data attributes
data-state vocabulary
| Piece | Values | |
|---|---|---|
[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-*
| Attribute | When present | |
|---|---|---|
data-disabled | Root or step is disabled | |
data-orientation | Always: horizontal or vertical | |
data-mode | Always (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
classandstyleare 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
| Key | Action | |
|---|---|---|
ArrowRight / ArrowDown | Move focus to the next trigger | |
ArrowLeft / ArrowUp | Move focus to the previous trigger | |
Home | Move focus to the first trigger | |
End | Move focus to the last trigger | |
Space / Enter | Activate focused trigger (manual mode) | |
Tab | Move 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 carriesrole="tablist", and content panels carryrole="tabpanel". Each<li forStepperItem>carriesrole="presentation"so thetablistowns thetabtriggers directly. An interposed implicitlistitemwould violate the tablist's required-owned-elements contract.aria-selectedis always emitted, andaria-controlsis emitted on every trigger whose panel is registered, so a panel kept mounted while inactive is referenced and one unmounted with@ifis 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@iftherefore never shifts the pairing of the others. - Progress mode uses a standard
<ol role="list">witharia-current="step"on the active trigger; each<li forStepperItem>keeps its implicitlistitemrole. No tab-stop manipulation is performed; triggers carry norole. - Disabled triggers in interactive mode reflect
aria-disabled="true"+data-disabled=""rather than the nativedisabledattribute. They leave theTabsequence but stay reachable with the arrow keys andHome/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-disabledstays reserved for an explicitdisabled, so style an unreachable step off[aria-disabled]. Like a disabled trigger, an unreachable one is reached by arrow navigation but not activated, andactivationMode="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 exposesrole="progressbar"witharia-valuemin="0",aria-valuemax="100", andaria-valuenowderived from the current step or the count of completed steps. - Panel
tabindexfollows the Tabs pattern inmode="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). Inmode="progress"notabindexis 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.