Stepper
A step-by-step flow — horizontal or vertical, optional linear gating, driven by a small hook; in React (@bpdm/ui) and Angular (@bpdm/ng).
The Stepper walks a user through a flow — onboarding, checkout, a wizard.
Compose StepList (the markers) and StepPanels (the content); drive navigation
with the useStepper() hook (React) or a #s="bpdmStepper" template ref (Angular).
Horizontal or vertical, with optional linear gating, and localizable a11y copy
via messages.
Usage
Put a Step per stage in StepList, a matching StepPanel in StepPanels, then
wire Back / Next / Finish from the stepper's state. The navigation bar reads that
state from context, so factor it into its own component and drop it anywhere inside
<Stepper>.
Tell us who you are — name and email.
import { useStepper } from '@bpdm/ui/stepper';
import { Button } from '@bpdm/ui/button';
// reads the stepper state from context — render it anywhere inside <Stepper>
export function Nav() {
const { isFirst, isLast, next, back, complete } = useStepper();
return (
<div className="mt-4 flex justify-between">
<Button variant="secondary" appearance="ghost" size="sm" onClick={back} disabled={isFirst}>
Back
</Button>
{isLast ? (
<Button size="sm" onClick={complete}>Finish</Button>
) : (
<Button size="sm" onClick={next}>Next</Button>
)}
</div>
);
}import { Stepper, StepList, Step, StepPanels, StepPanel } from '@bpdm/ui/stepper';
import { Nav } from './nav';
export function Onboarding() {
return (
<Stepper defaultValue="1">
<StepList>
<Step value="1">Account</Step>
<Step value="2">Workspace</Step>
<Step value="3">Review</Step>
</StepList>
<StepPanels>
<StepPanel value="1">Tell us who you are — name and email.</StepPanel>
<StepPanel value="2">Name your workspace and pick a URL.</StepPanel>
<StepPanel value="3">Everything looks good — review and finish.</StepPanel>
</StepPanels>
<Nav />
</Stepper>
);
}Expose the stepper as #s="bpdmStepper" and call s.next() / s.back() /
s.complete() from a plain button bar in the template.
import { Component } from '@angular/core';
import { BpdmStepper, BpdmStepList, BpdmStep, BpdmStepPanels, BpdmStepPanel, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'onboarding',
imports: [BpdmStepper, BpdmStepList, BpdmStep, BpdmStepPanels, BpdmStepPanel, BpdmButton],
template: `
<bpdm-stepper #s="bpdmStepper" defaultValue="1">
<bpdm-step-list>
<bpdm-step value="1">Account</bpdm-step>
<bpdm-step value="2">Workspace</bpdm-step>
<bpdm-step value="3">Review</bpdm-step>
</bpdm-step-list>
<bpdm-step-panels>
<bpdm-step-panel value="1">Tell us who you are — name and email.</bpdm-step-panel>
<bpdm-step-panel value="2">Name your workspace and pick a URL.</bpdm-step-panel>
<bpdm-step-panel value="3">Everything looks good — review and finish.</bpdm-step-panel>
</bpdm-step-panels>
<div class="mt-4 flex justify-between">
<button bpdmButton variant="secondary" appearance="ghost" size="sm" (click)="s.back()" [disabled]="s.isFirst()">Back</button>
@if (s.isLast()) {
<button bpdmButton size="sm" (click)="s.complete()">Finish</button>
} @else {
<button bpdmButton size="sm" (click)="s.next()">Next</button>
}
</div>
</bpdm-stepper>
`,
})
export class Onboarding {}Vertical
Set orientation="vertical" for a stacked flow — good for a sidebar wizard. The
markers stack and the active panel expands beneath its step. (For a connected rail
between markers, wrap each Step + StepPanel in a StepItem / <bpdm-step-item>.)
Tell us who you are.
import { Stepper, StepItem, Step, StepPanel } from '@bpdm/ui/stepper';
import { Nav } from './nav';
// vertical wraps each Step + StepPanel in a StepItem (draws the rail); the Step and
// StepPanel inherit their value from the enclosing StepItem
export function VerticalOnboarding() {
return (
<Stepper defaultValue="1" orientation="vertical">
<StepItem value="1">
<Step>Account</Step>
<StepPanel>Tell us who you are.</StepPanel>
</StepItem>
<StepItem value="2">
<Step>Workspace</Step>
<StepPanel>Name your workspace.</StepPanel>
</StepItem>
<StepItem value="3">
<Step>Review</Step>
<StepPanel>Review and finish.</StepPanel>
</StepItem>
<Nav />
</Stepper>
);
}import { Component } from '@angular/core';
import { BpdmStepper, BpdmStepItem, BpdmStep, BpdmStepPanel, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'vertical-onboarding',
imports: [BpdmStepper, BpdmStepItem, BpdmStep, BpdmStepPanel, BpdmButton],
template: `
<bpdm-stepper #s="bpdmStepper" defaultValue="1" orientation="vertical">
<bpdm-step-item value="1">
<bpdm-step>Account</bpdm-step>
<bpdm-step-panel>Tell us who you are.</bpdm-step-panel>
</bpdm-step-item>
<bpdm-step-item value="2">
<bpdm-step>Workspace</bpdm-step>
<bpdm-step-panel>Name your workspace.</bpdm-step-panel>
</bpdm-step-item>
<bpdm-step-item value="3">
<bpdm-step>Review</bpdm-step>
<bpdm-step-panel>Review and finish.</bpdm-step-panel>
</bpdm-step-item>
<div class="mt-4 flex justify-between">
<button bpdmButton variant="secondary" appearance="ghost" size="sm" (click)="s.back()" [disabled]="s.isFirst()">Back</button>
@if (s.isLast()) {
<button bpdmButton size="sm" (click)="s.complete()">Finish</button>
} @else {
<button bpdmButton size="sm" (click)="s.next()">Next</button>
}
</div>
</bpdm-stepper>
`,
})
export class VerticalOnboarding {}Linear gating
linear stops users from jumping ahead — future steps aren't clickable until they're
reached. Add lockIndicator to show a lock on the steps that aren't reachable yet
(they're disabled, so keyboard and screen readers skip them and announce them as
locked).
Future steps are locked until you advance.
import { Stepper, StepList, Step, StepPanels, StepPanel } from '@bpdm/ui/stepper';
import { Nav } from './nav';
export function LinearOnboarding() {
return (
<Stepper defaultValue="1" linear lockIndicator>
<StepList>
<Step value="1">Account</Step>
<Step value="2">Workspace</Step>
<Step value="3">Review</Step>
</StepList>
<StepPanels>
<StepPanel value="1">Future steps are locked until you advance.</StepPanel>
<StepPanel value="2">Now step 3 is reachable.</StepPanel>
<StepPanel value="3">Review and finish.</StepPanel>
</StepPanels>
<Nav />
</Stepper>
);
}import { Component } from '@angular/core';
import { BpdmStepper, BpdmStepList, BpdmStep, BpdmStepPanels, BpdmStepPanel, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'linear-onboarding',
imports: [BpdmStepper, BpdmStepList, BpdmStep, BpdmStepPanels, BpdmStepPanel, BpdmButton],
template: `
<bpdm-stepper #s="bpdmStepper" defaultValue="1" linear lockIndicator>
<bpdm-step-list>
<bpdm-step value="1">Account</bpdm-step>
<bpdm-step value="2">Workspace</bpdm-step>
<bpdm-step value="3">Review</bpdm-step>
</bpdm-step-list>
<bpdm-step-panels>
<bpdm-step-panel value="1">Future steps are locked until you advance.</bpdm-step-panel>
<bpdm-step-panel value="2">Now step 3 is reachable.</bpdm-step-panel>
<bpdm-step-panel value="3">Review and finish.</bpdm-step-panel>
</bpdm-step-panels>
<div class="mt-4 flex justify-between">
<button bpdmButton variant="secondary" appearance="ghost" size="sm" (click)="s.back()" [disabled]="s.isFirst()">Back</button>
@if (s.isLast()) {
<button bpdmButton size="sm" (click)="s.complete()">Finish</button>
} @else {
<button bpdmButton size="sm" (click)="s.next()">Next</button>
}
</div>
</bpdm-stepper>
`,
})
export class LinearOnboarding {}Validated steps
Gate Next on the current step's validity — just disable the button from your own
state (here, a checkbox must be ticked). Because the nav bar owns this rule, give it
its own canNext prop rather than reusing the shared Nav.
Next stays disabled until you accept.
import { useState } from 'react';
import { Stepper, StepList, Step, StepPanels, StepPanel, useStepper } from '@bpdm/ui/stepper';
import { Button } from '@bpdm/ui/button';
import { Checkbox } from '@bpdm/ui/checkbox';
// a nav bar whose Next is gated on the caller's validity flag
function Nav({ canNext }: { canNext: boolean }) {
const { isFirst, isLast, next, back, complete } = useStepper();
return (
<div className="mt-4 flex justify-between">
<Button variant="secondary" appearance="ghost" size="sm" onClick={back} disabled={isFirst}>
Back
</Button>
{isLast ? (
<Button size="sm" onClick={complete}>Finish</Button>
) : (
<Button size="sm" onClick={next} disabled={!canNext}>Next</Button>
)}
</div>
);
}
export function ValidatedOnboarding() {
const [agreed, setAgreed] = useState(false);
return (
<Stepper defaultValue="1" linear>
<StepList>
<Step value="1">Terms</Step>
<Step value="2">Confirm</Step>
</StepList>
<StepPanels>
<StepPanel value="1">
<label className="flex w-fit cursor-pointer items-center gap-2.5 text-sm">
<Checkbox size="sm" checked={agreed} onCheckedChange={(v) => setAgreed(!!v)} />
I accept the terms and conditions
</label>
<p className="mt-1.5 text-xs text-muted-foreground">Next stays disabled until you accept.</p>
</StepPanel>
<StepPanel value="2">All set — confirm to finish.</StepPanel>
</StepPanels>
<Nav canNext={agreed} />
</Stepper>
);
}import { Component, signal } from '@angular/core';
import { BpdmStepper, BpdmStepList, BpdmStep, BpdmStepPanels, BpdmStepPanel, BpdmButton, BpdmCheckbox } from '@bpdm/ng';
@Component({
selector: 'validated-onboarding',
imports: [BpdmStepper, BpdmStepList, BpdmStep, BpdmStepPanels, BpdmStepPanel, BpdmButton, BpdmCheckbox],
template: `
<bpdm-stepper #s="bpdmStepper" defaultValue="1" linear>
<bpdm-step-list>
<bpdm-step value="1">Terms</bpdm-step>
<bpdm-step value="2">Confirm</bpdm-step>
</bpdm-step-list>
<bpdm-step-panels>
<bpdm-step-panel value="1">
<label class="flex items-center gap-2 text-sm">
<bpdm-checkbox size="sm" [checked]="agreed()" (checkedChange)="agreed.set(!!$event)" />
I accept the terms — Next stays disabled until checked.
</label>
</bpdm-step-panel>
<bpdm-step-panel value="2">All set — confirm to finish.</bpdm-step-panel>
</bpdm-step-panels>
<div class="mt-4 flex justify-between">
<button bpdmButton variant="secondary" appearance="ghost" size="sm" (click)="s.back()" [disabled]="s.isFirst()">Back</button>
@if (s.isLast()) {
<button bpdmButton size="sm" (click)="s.complete()">Finish</button>
} @else {
<button bpdmButton size="sm" (click)="s.next()" [disabled]="!agreed()">Next</button>
}
</div>
</bpdm-stepper>
`,
})
export class ValidatedOnboarding {
readonly agreed = signal(false);
}Custom icons
Give a Step an icon to replace its number. Completed steps still show a check
unless you override it. In React the icon is any node; in Angular it's a
<ng-template> referenced from the step.
Tell us who you are.
import { Stepper, StepList, Step, StepPanels, StepPanel } from '@bpdm/ui/stepper';
import { User, Building2, ClipboardCheck } from 'lucide-react';
import { Nav } from './nav';
export function IconOnboarding() {
return (
<Stepper defaultValue="1">
<StepList>
<Step value="1" icon={<User />}>Account</Step>
<Step value="2" icon={<Building2 />}>Workspace</Step>
<Step value="3" icon={<ClipboardCheck />}>Review</Step>
</StepList>
<StepPanels>
<StepPanel value="1">Tell us who you are — name and email.</StepPanel>
<StepPanel value="2">Name your workspace and pick a URL.</StepPanel>
<StepPanel value="3">Everything looks good — review and finish.</StepPanel>
</StepPanels>
<Nav />
</Stepper>
);
}import { Component } from '@angular/core';
import { BpdmStepper, BpdmStepList, BpdmStep, BpdmStepPanels, BpdmStepPanel, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'icon-onboarding',
imports: [BpdmStepper, BpdmStepList, BpdmStep, BpdmStepPanels, BpdmStepPanel, BpdmButton],
template: `
<bpdm-stepper #s="bpdmStepper" defaultValue="1">
<bpdm-step-list>
<bpdm-step value="1" [icon]="account">Account</bpdm-step>
<bpdm-step value="2" [icon]="workspace">Workspace</bpdm-step>
<bpdm-step value="3" [icon]="review">Review</bpdm-step>
</bpdm-step-list>
<bpdm-step-panels>
<bpdm-step-panel value="1">Tell us who you are — name and email.</bpdm-step-panel>
<bpdm-step-panel value="2">Name your workspace and pick a URL.</bpdm-step-panel>
<bpdm-step-panel value="3">Everything looks good — review and finish.</bpdm-step-panel>
</bpdm-step-panels>
<div class="mt-4 flex justify-between">
<button bpdmButton variant="secondary" appearance="ghost" size="sm" (click)="s.back()" [disabled]="s.isFirst()">Back</button>
@if (s.isLast()) {
<button bpdmButton size="sm" (click)="s.complete()">Finish</button>
} @else {
<button bpdmButton size="sm" (click)="s.next()">Next</button>
}
</div>
</bpdm-stepper>
<ng-template #account><!-- your icon svg --></ng-template>
<ng-template #workspace><!-- your icon svg --></ng-template>
<ng-template #review><!-- your icon svg --></ng-template>
`,
})
export class IconOnboarding {}Steps only
Drop the StepPanels to use the stepper purely as a progress indicator (e.g. a
checkout header) — the markers show which step is active and which are done. Drive it
with a controlled value since there's no nav.
import { Stepper, StepList, Step } from '@bpdm/ui/stepper';
export function CheckoutProgress({ current }: { current: string }) {
return (
<Stepper value={current}>
<StepList>
<Step value="1">Account</Step>
<Step value="2">Workspace</Step>
<Step value="3">Review</Step>
</StepList>
</Stepper>
);
}import { Component, input } from '@angular/core';
import { BpdmStepper, BpdmStepList, BpdmStep } from '@bpdm/ng';
@Component({
selector: 'checkout-progress',
imports: [BpdmStepper, BpdmStepList, BpdmStep],
template: `
<bpdm-stepper [value]="current()">
<bpdm-step-list>
<bpdm-step value="1">Account</bpdm-step>
<bpdm-step value="2">Workspace</bpdm-step>
<bpdm-step value="3">Review</bpdm-step>
</bpdm-step-list>
</bpdm-stepper>
`,
})
export class CheckoutProgress {
readonly current = input('1');
}Internationalization
- The stepper renders almost no copy of its own — every step label and panel body comes from your data, so you localize those at the data level: translate the children you pass and the stepper renders them as-is.
- One prop of translatable a11y strings —
messages. It's aPartial<StepperMessages>merged over the English defaults, so translate only what you need. It localizes the step list's accessible name (ariaLabel) and the visually-hidden per-step status each step announces — its position (step, e.g.Schritt {index} von {total}, with{index}and{total}filled in) plus its state (completed/current/upcoming/locked). There's nothing else to translate. - RTL-safe by construction: the stepper mirrors automatically under
dir="rtl"(logical properties throughout, and the connector fill grows from the inline-start), with no prop required. You usually don't setdiryourself; it inherits the ambient direction. The German example below is left-to-right; for a right-to-left locale, render it inside adir="rtl"container (or inheritdirfrom the page). See the Internationalization guide.
import { Stepper, StepList, Step, StepPanels, StepPanel, useStepper } from '@bpdm/ui/stepper';
import { Button } from '@bpdm/ui/button';
// German (de-DE) — translate the a11y strings via `messages`
const messages = {
ariaLabel: 'Fortschritt',
completed: 'Abgeschlossen',
current: 'Aktueller Schritt',
upcoming: 'Nicht abgeschlossen',
locked: 'Gesperrt',
step: 'Schritt {index} von {total}',
};
// German nav copy — Zurück / Weiter / Abschließen
function Nav() {
const { isFirst, isLast, next, back, complete } = useStepper();
return (
<div className="mt-4 flex justify-between">
<Button variant="secondary" appearance="ghost" size="sm" onClick={back} disabled={isFirst}>
Zurück
</Button>
{isLast ? (
<Button size="sm" onClick={complete}>Abschließen</Button>
) : (
<Button size="sm" onClick={next}>Weiter</Button>
)}
</div>
);
}
export function GermanStepper() {
return (
<Stepper defaultValue="1" messages={messages}>
<StepList>
<Step value="1">Konto</Step>
<Step value="2">Arbeitsbereich</Step>
<Step value="3">Überprüfen</Step>
</StepList>
<StepPanels>
<StepPanel value="1">Sagen Sie uns, wer Sie sind — Name und E-Mail.</StepPanel>
<StepPanel value="2">Benennen Sie Ihren Arbeitsbereich und wählen Sie eine URL.</StepPanel>
<StepPanel value="3">Alles sieht gut aus — überprüfen und abschließen.</StepPanel>
</StepPanels>
<Nav />
</Stepper>
);
}import { Component } from '@angular/core';
import { BpdmStepper, BpdmStepList, BpdmStep, BpdmStepPanels, BpdmStepPanel, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'german-stepper',
imports: [BpdmStepper, BpdmStepList, BpdmStep, BpdmStepPanels, BpdmStepPanel, BpdmButton],
template: `
<bpdm-stepper #s="bpdmStepper" defaultValue="1" [messages]="messages">
<bpdm-step-list>
<bpdm-step value="1">Konto</bpdm-step>
<bpdm-step value="2">Arbeitsbereich</bpdm-step>
<bpdm-step value="3">Überprüfen</bpdm-step>
</bpdm-step-list>
<bpdm-step-panels>
<bpdm-step-panel value="1">Sagen Sie uns, wer Sie sind — Name und E-Mail.</bpdm-step-panel>
<bpdm-step-panel value="2">Benennen Sie Ihren Arbeitsbereich und wählen Sie eine URL.</bpdm-step-panel>
<bpdm-step-panel value="3">Alles sieht gut aus — überprüfen und abschließen.</bpdm-step-panel>
</bpdm-step-panels>
<div class="mt-4 flex justify-between">
<button bpdmButton variant="secondary" appearance="ghost" size="sm" (click)="s.back()" [disabled]="s.isFirst()">Zurück</button>
@if (s.isLast()) {
<button bpdmButton size="sm" (click)="s.complete()">Abschließen</button>
} @else {
<button bpdmButton size="sm" (click)="s.next()">Weiter</button>
}
</div>
</bpdm-stepper>
`,
})
export class GermanStepper {
// German (de-DE) — translate the a11y strings via `messages`
readonly messages = {
ariaLabel: 'Fortschritt',
completed: 'Abgeschlossen',
current: 'Aktueller Schritt',
upcoming: 'Nicht abgeschlossen',
locked: 'Gesperrt',
step: 'Schritt {index} von {total}',
};
}API
Stepper / <bpdm-stepper>
| Prop | Type | Default | Description |
|---|---|---|---|
value / defaultValue / onValueChange | string | number | first step | Active step — controlled or uncontrolled. Angular: [(value)] + defaultValue. |
orientation | horizontal | vertical | horizontal | Layout direction. |
linear | boolean | false | Require the current step before advancing (future steps aren't clickable). |
lockIndicator | boolean | false | Show a lock on steps not reachable yet (with linear). |
messages | Partial<StepperMessages> | English defaults | Localize the a11y strings — the step-list name + each step's visually-hidden status. |
className | string (Angular class) | — | Extra classes. |
StepperMessages
Every key is optional (Partial<StepperMessages>) and falls back to the English
default below.
| Key | Default | Description |
|---|---|---|
ariaLabel | Progress | Accessible name for the step list (role="list"). |
completed | Completed | Announced status of a completed step. |
current | Current step | Announced status of the active step. |
upcoming | Not completed | Announced status of an upcoming step. |
locked | Locked | Announced status of a locked (unreachable) step. |
step | Step {index} of {total} | Position template — {index} and {total} are substituted per step. |
Parts
| Part | React | Angular |
|---|---|---|
| Marker row | <StepList> | <bpdm-step-list> |
| Step | <Step value icon disabled> | <bpdm-step value [icon] [disabled]> |
| Panels | <StepPanels> | <bpdm-step-panels> |
| Panel | <StepPanel value> | <bpdm-step-panel value> |
| Vertical item (draws the rail) | <StepItem value> | <bpdm-step-item value> |
A StepItem / <bpdm-step-item> wraps a Step + StepPanel and carries the value
down, so the children don't repeat it — use it for a connected vertical rail.
Navigation — useStepper() (React)
Call useStepper() from anywhere inside <Stepper> for state + navigation.
| Member | Type | Description |
|---|---|---|
value | string | Active step value. |
activeIndex | number | Zero-based index of the active step. |
steps | string[] | Step values in declaration order. |
count | number | Total number of steps. |
isFirst / isLast | boolean | Whether the active step is the first / last. |
next() / back() | () => void | Move to the next / previous step. |
activate(value) | (value: string) => void | Jump to a step (respects linear gating). |
complete() | () => void | Mark the whole flow done — every step shows completed. |
finished | boolean | true after complete(), until the user navigates again. |
In Angular, export the stepper as #s="bpdmStepper" (or inject BpdmStepper) for
the same surface as signals + methods: s.value(), s.activeIndex(), s.steps(),
s.isFirst(), s.isLast(), s.next(), s.back(), s.activate(v), s.complete().
(count and finished are React-only — use s.steps().length in Angular.)
Accessibility
Both frameworks implement the same process-steps pattern (a progress indicator, not a tabset — so no roving-tabindex/arrow-key tab contract):
- Roles and wiring. The step list is a
role="list"with anaria-label(frommessages.ariaLabel), and each step is a<button>(arole="listitem"); the active step carriesaria-current="step". A step is linked to its panel both ways —aria-controlsfrom the step button to itsrole="region"panel, andaria-labelledbyfrom the panel back to the step. Inactive vertical panels stay mounted for the collapse animation but areinert(andaria-hidden), so keyboard and screen readers skip their contents entirely. - Status is announced, not colour-only. Each step renders a visually-hidden
status combining its position and state — e.g. "Step 2 of 3, Current step" — so
completed / current / upcoming / locked is spoken, never conveyed by colour alone.
Completed steps also show a check, not just a filled marker. All six strings are
translatable via
messages. - Linear and locked steps. With
linear, steps that aren't reachable yet aredisabled(non-interactive) — so keyboard and screen readers skip them; withlockIndicatorthey show a lock and announce "Locked". - Keyboard. Tab moves between the steps (each reachable step is its own tab stop — there is no arrow-key roving, matching the process-steps pattern), and Enter / Space activates a reachable step. The Back / Next / Finish controls are real buttons that drive the flow, so it's fully keyboard-operable end to end.
- Focus. Steps show a visible focus-visible ring.
- Motion. The marker state change, the connector fill, and the panel's height/fade are subtle and non-essential to understanding the flow.
Tabs
Accessible tabs (roving focus, arrow keys) — underline or pill, horizontal or vertical, full-width, icons; data-driven or composed, in React (@bpdm/ui) and Angular (@bpdm/ng).
Dialog
A modal dialog — focus-trapped, scroll-locked, ESC/outside-click to close, in React (@bpdm/ui) and Angular (@bpdm/ng).