ng-hub-ui-stepper
v22.12.1
Published
A flexible and customizable stepper component for Angular applications
Maintainers
Readme
ng-hub-ui-stepper
Español | English
A flexible, customizable, and accessible stepper component for Angular 21+. Perfect for multi-step forms, wizards, and guided user experiences with a focus on developer experience and modern standards.
[!IMPORTANT] Version
22.12.1targets Angular 22 and uses the Signals architecture shared acrossng-hub-ui.
Documentation and Live Examples
This package is part of Hub UI, a collection of Angular component libraries for standalone apps.
- Docs: https://hubui.dev/en/stepper/overview/
- Live examples: https://hubui.dev/en/stepper/examples/
- Hub UI: https://hubui.dev/en/
- Hub UI on GitHub (issues, roadmap and contributing): https://github.com/hub-env/hub-ui
🧩 Library Family ng-hub-ui
This library is part of the ng-hub-ui ecosystem:
- ng-hub-ui-accordion (deprecated — use ng-hub-ui-panels)
- ng-hub-ui-action-sheet
- ng-hub-ui-avatar
- ng-hub-ui-board
- ng-hub-ui-breadcrumbs
- ng-hub-ui-calendar
- ng-hub-ui-dropdown
- ng-hub-ui-ds
- ng-hub-ui-forms
- ng-hub-ui-history
- ng-hub-ui-milestones
- ng-hub-ui-modal
- ng-hub-ui-nav
- ng-hub-ui-paginable
- ng-hub-ui-panels
- ng-hub-ui-portal
- ng-hub-ui-skeleton
- ng-hub-ui-sortable
- ng-hub-ui-spreadsheet
- ng-hub-ui-stepper ← You are here
- ng-hub-ui-utils
Table of Contents
- Features
- Installation
- Usage (Quick Start)
- Examples
- API Reference
- Internationalization
- Styling
- Contributing
- License
Features
- 🚀 Angular 21+ Built-in: Uses Signals and new control flow syntax.
- 🎨 Highly Customizable: Easy to theme via CSS variables, the
hub-stepper-theme()Sass mixin and custom templates. - ♿ Accessible: WAI-ARIA tablist rail with full keyboard navigation.
- 🔢 Multi-layout: Supports Vertical, Sidebar, and RTL modes.
- 🔄 Smooth Transitions: Opt-in CSS animations, enabled with the
stepper--animatedhost class (see Step transitions). No@angular/animationsdependency. - 🧩 Flexible Controls: Use default buttons or project your own.
- ✅ Step validity: tell each step whether it passes with
[valid]; the stepper turns that intoinOrderand decides what the user can reach. - 🔢 Two rails: the pill rail, or the inline
nav="track"with numbered markers, a connector and a tick on every step in order. - ⏸️ Interceptable jumps:
beforeStepChangeruns before the step moves, so you can save what the user typed — and refuse the jump if the save fails. - ✂️ Opt-in title truncation + tooltip: set
truncateTitlesto clip long nav titles (bounded by--hub-stepper-nav-title-max-width) and reveal the full text on hover — hub-ui tooltip by default, swappable withprovideHubTooltip. Requiresng-hub-ui-utils >= 22.6.0+@use 'ng-hub-ui-utils/styles/tooltip';.
ℹ️ What the stepper does not do: it never inspects your forms. It does listen, though — state the verdict with
[valid]on the step and the stepper derives what the user can reach from it (see Inline track rail).canNavigateTo()is unchanged and still answers on the step'sdisabledinput alone.
🔤 Three words this library keeps apart: a step is visited when the user has actually stood on it, valid when your component says its data passes, in order when it is either stated valid or — with nothing stated — visited, and reachable when the track will let the user jump to it from where they are.
isCompletedin the trigger template context is none of these: it isindex < currentIndex, position and nothing else, and it is left that way.
♿ Accessibility model: the step rail is a WAI-ARIA
tablist(each trigger atab, each step content atabpanel) with a roving tabindex, so it is a single Tab stop. Arrow keys move focus between enabled steps (skipping disabled ones, wrapping),Home/Endjump to the first/last enabled step, andEnter/Spaceactivates the focused step under the same rules as clicking it. The rail's accessible name comes from therailLabelinput (default'Steps').
Installation
npm install ng-hub-ui-stepperUsage (Quick Start)
Import the standalone building blocks your template uses:
import { StepComponent, StepperComponent } from 'ng-hub-ui-stepper';
@Component({
standalone: true,
imports: [StepperComponent, StepComponent]
// ...
})
export class YourComponent {}The rest of the surface — StepTriggerDirective, StepperNavDirective, PreviousButtonDirective,
NextButtonDirective and SubmitButtonDirective — is imported the same way, one by one, as each
template needs it.
Then register the library once, so the built-in Back / Continue / Submit controls have text:
import { provideHubStepper } from 'ng-hub-ui-stepper';
bootstrapApplication(AppComponent, {
providers: [provideHubStepper({ language: 'en' })]
});
StepperModuleis deprecated and will be removed in 23.0.0. It only re-exports the seven building blocks above, so importing them directly is the whole migration, andStepperModule.forRoot()becomesprovideHubStepper()— same providers, no module. See Providers and Internationalization.
In your template:
<hub-stepper>
<hub-step title="Account Setup">
<h3>Welcome!</h3>
<p>Setup your account details here.</p>
</hub-step>
<hub-step title="Personal Info">
<h3>Profile Data</h3>
<p>Tell us more about yourself.</p>
</hub-step>
<hub-step title="Review">
<h3>Save & Finalize</h3>
<p>Ready to go?</p>
</hub-step>
</hub-stepper>Examples
Linear Stepper
Control navigation by enabling/disabling steps programmatically.
<hub-stepper (completed)="onFinish()">
<hub-step title="Step 1">
<!-- Step 1 Content -->
</hub-step>
<hub-step title="Step 2" [disabled]="!isStep1Valid()">
<!-- Step 2 Content -->
</hub-step>
</hub-stepper>Custom Navigation
Mark an ng-template with the hubStepperNav (or stepperNav) directive and it replaces the whole
built-in rail. The context gives you steps — the projected StepComponent instances, so title and
disabled are signals — and currentIndex. A template reference on the stepper gets you goTo().
<hub-stepper #stepper>
<ng-template hubStepperNav let-steps="steps" let-currentIndex="currentIndex">
<ol class="my-custom-nav">
@for (step of steps; track step; let i = $index) {
<li>
<button
type="button"
[class.active]="i === currentIndex"
[disabled]="!stepper.canNavigateTo(i)"
(click)="stepper.goTo(i)"
>
{{ step.title() || 'Step ' + (i + 1) }}
</button>
</li>
}
</ol>
</ng-template>
<hub-step title="A">...</hub-step>
<hub-step title="B">...</hub-step>
</hub-stepper>Replacing the rail also replaces its accessibility: the WAI-ARIA tablist, the roving tabindex and the arrow-key handling described above belong to the built-in rail. A custom template owns its own semantics.
Custom Buttons
Project your own buttons to override the default footer. Each directive wires the click and keeps the button disabled while the move is unavailable.
<hub-stepper>
<hub-step>...</hub-step>
<button previousButton class="btn-back">Go back</button>
<button nextButton class="btn-next">Next step</button>
<button submitButton class="btn-done">Complete</button>
</hub-stepper>Step transitions
Transitions are plain CSS and opt-in: add hub-stepper--animated to the host, then pick the flavour
with hub-stepper--anim-slide (the default when neither is set) or hub-stepper--anim-fade. Duration
comes from --hub-stepper-animation-duration. The unprefixed stepper--animated,
stepper--anim-slide and stepper--anim-fade are still read, and go in 23.0.0.
<hub-stepper class="hub-stepper--animated hub-stepper--anim-fade" [style.--hub-stepper-animation-duration.ms]="240">
<hub-step title="Profile">...</hub-step>
<hub-step title="Summary">...</hub-step>
</hub-stepper>Custom rail triggers
hubStepperNav replaces the whole rail. When all you want is a different control inside each rail
item — a numbered circle, an icon, a check on the steps already done — hubStepTrigger is one level
down: the rail keeps its list, its role="tablist", its orientation and its label, and only the
button inside each item is yours.
<hub-stepper #wizard>
<ng-template hubStepTrigger let-title="title" let-index="index" let-isCurrent="isCurrent" let-disabled="disabled">
<button type="button" role="tab" [attr.aria-selected]="isCurrent" [disabled]="disabled" (click)="wizard.goTo(index)">
<span class="badge">{{ index + 1 }}</span> {{ title }}
</button>
</ng-template>
<hub-step title="Account">...</hub-step>
<hub-step title="Payment">...</hub-step>
</hub-stepper>The context carries the step as $implicit and as step, plus title, index, isCurrent,
isCompleted, disabled, visited, valid, inOrder and reachable. isCompleted is position
alone (index < currentIndex) and always has been — inOrder is the one that knows about validity. Activating a step stays with you, through a template reference on the
host — which is why the example names the stepper #wizard. Give the trigger role="tab" if you
want the rail's arrow-key navigation to keep finding it.
A stepper that declares both templates uses hubStepperNav and ignores this one.
Inline track rail
nav="track" swaps the pill rail for a track: one numbered marker per step, joined by a connector,
with a tick in the corner of every step that is in order — the number stays visible under it. The
default is nav="pills", so nothing changes until you ask.
The track also enforces a rule the pills rail does not: a jump back is always allowed, a jump forward only once every step in between is in order.
<hub-stepper nav="track" [beforeStepChange]="saveBeforeLeaving">
<hub-step title="Account" [valid]="accountForm.valid">...</hub-step>
<hub-step title="Address" [valid]="addressForm.valid">...</hub-step>
<hub-step title="Payment" [valid]="paymentForm.valid">...</hub-step>
<hub-step title="Review">...</hub-step>
</hub-stepper>[valid] is tri-state on purpose. true and false are your verdict; leaving it unbound means
"nobody has looked yet", and the step then counts as in order once it has been visited — which is
how a stepper that never mentions validity keeps behaving exactly as it always did.
Saving before a jump
beforeStepChange runs while the stepper is still on the step being left, which is the only
moment at which saving is useful. Return false, a promise resolving to false, or a promise that
rejects, and the move is cancelled.
saveBeforeLeaving = async ({ from, to }: StepperStepChange): Promise<boolean> => {
try {
await this.drafts.save(from);
return true;
} catch {
this.toast.error(`Could not save step ${from + 1}`);
return false;
}
};An output cannot do this job: by the time one fires the step has already changed and there is
nothing left to refuse. It is the same contract ng-hub-ui-portal gives beforeDismiss.
API Reference
StepperComponent (hub-stepper)
| Input | Type | Default | Description |
| --- | --- | --- | --- |
| variant | string | undefined (renders as primary) | Semantic accent for the active step pill and the next / submit controls. Built-in values: primary, secondary, success, danger, warning, info, neutral, light, dark. Any other string is also accepted and resolves through --hub-sys-color-<variant>. |
| backLabel | string \| null | null | Overrides the back button label. While null, the translated BACK label is used. |
| continueLabel | string \| null | null | Overrides the continue button label. While null, the translated CONTINUE label is used. |
| submitLabel | string \| null | null | Overrides the submit button label. While null, the translated SUBMIT label is used. |
| truncateTitles | boolean | false | Clips each rail title to --hub-stepper-nav-title-max-width (default 12rem) and reveals the full text as a tooltip when it overflows. |
| railLabel | string | 'Steps' | Accessible name of the step rail tablist. |
| nav | 'pills' \| 'track' | 'pills' | Which rail to draw. 'track' paints the inline variant and gates a forward jump on the steps in between being in order. |
| inOrderLabel | string \| null | null | Overrides the status a screen reader hears on a ticked track marker. While null, the translated IN_ORDER label is used. |
| beforeStepChange | StepperStepChangeGuard \| null | null | Consulted before the active step changes, while the stepper is still on the step being left. Returning false, a promise resolving to false, or a promise that rejects cancels the move. |
| options | StepperOptions | {} | Visual and layout configuration. |
| Output | Type | Description |
| --- | --- | --- |
| completed | OutputEmitterRef<void> | Emitted when the last step is completed. |
| previousStep | OutputEmitterRef<number> | Emitted when moving back. Passes the new index. |
| nextStep | OutputEmitterRef<number> | Emitted when moving forward. Passes the new index. |
Public members you can reach through a template reference (<hub-stepper #stepper>):
| Member | Signature | Description |
| --------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| currentIndex | WritableSignal<number> | Index of the active step. |
| steps | Signal<readonly StepComponent[]> | The projected steps, in order. |
| currentStep | StepComponent \| null | The active step instance. |
| goTo | (index: number) => void | Activates a step, when canNavigateTo() allows it and beforeStepChange lets it through. Since 22.12.0 it refuses a disabled step, as the rail always has. |
| goToPrevious / goToNext | () => void | Moves one step back / forward. |
| canNavigateTo | (index: number) => boolean | true when the index exists and its step is not disabled. |
| isInOrder | (index: number) => boolean | true when the step is stated valid, or — with nothing stated — has been visited. |
| isReachable | (index: number) => boolean | What the track allows: backwards always, forwards only once every step in between is in order. A disabled step is closed off either way. |
| complete | () => void | Emits completed. |
StepComponent (hub-step)
| Input | Type | Default | Description |
| --- | --- | --- | --- |
| title | string \| undefined | undefined | Text displayed in the rail. Falls back to Step N when omitted. |
| disabled | boolean | false | Prevents navigation to this step through the rail and the built-in controls. |
| valid | boolean \| null | null | Your verdict on this step's data. null means nothing has been said, and the step then counts as in order once visited. |
Two read-only signals come with it: visited, set by the stepper when the step is actually reached
(a jump over it does not count, and walking back does not unset it), and inOrder, the stated
validity when there is one and visited otherwise.
index is not an input: the parent stepper assigns it. Reading it (step.index()) is fine; binding it is not.
Directives
| Directive | Selectors | Applies to | Purpose |
| ------------------------- | ---------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| NextButtonDirective | button[nextButton], button[continueButton] | <button> | Calls goToNext() and disables the button while the next step is not reachable. |
| PreviousButtonDirective | button[previousButton], button[backButton] | <button> | Calls goToPrevious() and disables the button when there is no enabled previous step. |
| SubmitButtonDirective | button[submitButton] | <button> | Calls complete() and disables the button while the current step is disabled or out of order. |
| StepperNavDirective | [hubStepperNav], [stepperNav] | <ng-template> | Replaces the built-in rail. Context: steps, currentIndex. |
| StepTriggerDirective | [hubStepTrigger], [stepTrigger] | <ng-template> | Replaces the rail trigger the default rail draws, once per step. Context: $implicit / step, title, index, isCurrent, isCompleted, disabled, visited, valid, inOrder, reachable. Ignored when a hubStepperNav template is present, and by nav="track", which draws its own marker, since a custom rail draws its own triggers. |
Host classes
Set these on <hub-stepper> itself; they are read by the stylesheet, not by inputs.
| Class | Effect |
| ------------------------- | ---------------------------------------------------------------------------------- |
| hub-stepper--animated | Enables the CSS transition between step panels. Without it, panels swap instantly. |
| hub-stepper--anim-slide | Slide transition (also the default when only hub-stepper--animated is set). |
| hub-stepper--anim-fade | Fade transition instead of the slide. |
Renamed in 22.10.0. These were
stepper--animated,stepper--anim-slideandstepper--anim-fade, and the component itself wore a barestepperclass.stepperis a word in the application's namespace, not the library's, so a host application with a.stepperrule of its own restyled the component from the outside. The whole block is nowhub-stepper, in line with every other library of the family. The old spellings still work and are still written to the DOM; both go in 23.0.0. SeeBREAKING_CHANGES.md.
Services
StepperThemeService
Provided in root. Writes --hub-stepper-* custom properties on documentElement, for themes decided at
runtime (a tenant colour arriving from an API, say). Keys are passed without the --hub-stepper-
prefix, which the service adds:
inject(StepperThemeService).setTheme({
accent: '#7c3aed',
'nav-link-active-color': '#ffffff',
gap: '1.5rem'
});For a theme known at build time, prefer the hub-stepper-theme() mixin: it scopes to a
selector instead of the document root.
Providers
provideHubStepper(config?: StepperConfig)
The standalone entry point. Registers the ten bundled dictionaries and the HubTranslationService
that TranslatePipe injects to resolve the built-in control labels — the service is not
providedIn: 'root', so without this (or another provider of it) the first render throws
NullInjectorError.
bootstrapApplication(AppComponent, {
providers: [provideHubStepper({ language: 'en', fallbackLanguage: 'en' })]
});It can also be scoped to the route that owns the wizard, which is what an application already
configuring provideHubTranslation() at the root should do — see
Internationalization for why.
STEPPER_DICTIONARIES
The bundled dictionaries as a plain record, keyed by language code, so you can register only the languages you ship or merge the labels into a dictionary of your own:
import { STEPPER_DICTIONARIES } from 'ng-hub-ui-stepper';
provideHubTranslation({
language: 'ca',
fallbackLanguage: 'en',
dictionaries: {
ca: { ...STEPPER_DICTIONARIES['ca'], ...myCatalanStrings },
en: { ...STEPPER_DICTIONARIES['en'], ...myEnglishStrings }
}
});The keys are flat — BACK, CONTINUE, SUBMIT — which is what the component resolves once its
HUBUI.STEPPER namespace misses.
Interfaces
StepperOptions
interface StepperOptions {
layout?: 'vertical' | 'sidebar';
rtl?: boolean;
}StepperNavVariant, StepperStepChange and StepperStepChangeGuard
type StepperNavVariant = 'pills' | 'track';
interface StepperStepChange {
from: number; // the index being left — still the active one while the guard runs
to: number; // the index the stepper would land on
}
type StepperStepChangeGuard = (change: StepperStepChange) => boolean | Promise<boolean>;StepperConfig
Accepted by provideHubStepper() and by the deprecated StepperModule.forRoot():
interface StepperConfig {
language?: string; // default 'es'
fallbackLanguage?: string; // default 'en'
}Internationalization
The built-in back, continue and submit labels go through TranslatePipe from ng-hub-ui-utils. There are
two ways to feed them, and they can be combined.
The bundled dictionaries. provideHubStepper() registers translations for en, es, ca, eu,
gl, ast, an, de, zh and ar, and the HubTranslationService that TranslatePipe injects:
providers: [provideHubStepper({ language: 'en', fallbackLanguage: 'en' })];StepperModule.forRoot({ language: 'en', fallbackLanguage: 'en' }) does the same and is removed in
23.0.0 along with the module; it now delegates to provideHubStepper(), so the swap changes nothing
at runtime.
One caveat: provideHubStepper() writes HUB_TRANSLATION_CONFIG, and that is a single
application-wide token. If your application already calls provideHubTranslation() at the root, do
not register both — whichever comes last wins and the other loses its dictionaries. Merge the stepper
labels into your own call with STEPPER_DICTIONARIES, or scope
provideHubStepper() to the route that owns the wizard.
Your application dictionary. Configure provideHubTranslationAdapter() once in app.config.ts; its
reactive dictionary updates the rendered navigation automatically. The component provides
HUB_TRANSLATION_PREFIX as HUBUI.STEPPER, so the namespaced keys are looked up first and the bare keys
remain as the fallback:
// Preferred — namespaced, so the stepper reserves no generic top-level keys:
// HUBUI.STEPPER.BACK, HUBUI.STEPPER.CONTINUE, HUBUI.STEPPER.SUBMIT
// Still honoured for existing flat dictionaries:
// BACK, CONTINUE, SUBMITPer instance, backLabel / continueLabel / submitLabel win over both.
Styling
Customize the component using CSS variables. For a complete list of available tokens, see the CSS Variables Reference.
.my-stepper {
--hub-stepper-primary-color: #0d6efd;
--hub-stepper-surface-color: #ffffff;
--hub-stepper-gap: 1.5rem;
}Semantic accent
The --hub-stepper-accent token drives the active step pill and the next / submit controls. It defaults to var(--hub-sys-color-primary). The easiest way to set it is the variant input (see API Reference), but you can also override the token directly:
.my-stepper {
--hub-stepper-accent: var(--hub-sys-color-success);
}Sass mixin
For full theming in a single call, the package ships a hub-stepper-theme() Sass mixin. Every parameter is optional and defaults to null, so only the ones you pass are emitted as --hub-stepper-* overrides:
@use 'ng-hub-ui-stepper/styles' as *;
.checkout-stepper {
@include hub-stepper-theme(
$accent: var(--hub-sys-color-success),
$gap: 1.5rem,
$nav-link-active-color: #fff,
$sidebar-width: 220px
);
}Contributing
We welcome contributions! Please follow our Commit Guidelines.
- Fork the repo.
- Create your feature branch (
git checkout -b feature/amazing-feature). - Commit your changes.
- Push to the branch.
- Create a Pull Request.
Support
If you find this project helpful, consider supporting its development:
Commercial support
These libraries are maintained by Carlos Morcillo Fernández, a freelance frontend architect working with teams that build and maintain Angular applications.
If your team depends on Hub-UI and needs more than an issue thread can solve, that is my day job: architecture audits, design systems, Angular migrations and team mentoring. For projects that also need design and a full team, I run them through Frog Hub, my development studio.
Have a look at the services or tell me about your project.
License
This project is licensed under the MIT License - see the LICENSE file for details.

