<bpdm/ui />
Panel

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.

nav.tsx
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>
  );
}
onboarding.tsx
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.

onboarding.ts
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.

vertical.tsx
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>
  );
}
vertical.ts
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.

linear.tsx
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>
  );
}
linear.ts
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.

validated.tsx
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>
  );
}
validated.ts
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.

icons.tsx
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>
  );
}
icons.ts
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.

steps-only.tsx
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>
  );
}
steps-only.ts
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 a Partial<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 set dir yourself; it inherits the ambient direction. The German example below is left-to-right; for a right-to-left locale, render it inside a dir="rtl" container (or inherit dir from the page). See the Internationalization guide.
stepper-de.tsx
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>
  );
}
stepper-de.ts
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>

PropTypeDefaultDescription
value / defaultValue / onValueChangestring | numberfirst stepActive step — controlled or uncontrolled. Angular: [(value)] + defaultValue.
orientationhorizontal | verticalhorizontalLayout direction.
linearbooleanfalseRequire the current step before advancing (future steps aren't clickable).
lockIndicatorbooleanfalseShow a lock on steps not reachable yet (with linear).
messagesPartial<StepperMessages>English defaultsLocalize the a11y strings — the step-list name + each step's visually-hidden status.
classNamestring (Angular class)Extra classes.

StepperMessages

Every key is optional (Partial<StepperMessages>) and falls back to the English default below.

KeyDefaultDescription
ariaLabelProgressAccessible name for the step list (role="list").
completedCompletedAnnounced status of a completed step.
currentCurrent stepAnnounced status of the active step.
upcomingNot completedAnnounced status of an upcoming step.
lockedLockedAnnounced status of a locked (unreachable) step.
stepStep {index} of {total}Position template — {index} and {total} are substituted per step.

Parts

PartReactAngular
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.

Call useStepper() from anywhere inside <Stepper> for state + navigation.

MemberTypeDescription
valuestringActive step value.
activeIndexnumberZero-based index of the active step.
stepsstring[]Step values in declaration order.
countnumberTotal number of steps.
isFirst / isLastbooleanWhether the active step is the first / last.
next() / back()() => voidMove to the next / previous step.
activate(value)(value: string) => voidJump to a step (respects linear gating).
complete()() => voidMark the whole flow done — every step shows completed.
finishedbooleantrue 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 an aria-label (from messages.ariaLabel), and each step is a <button> (a role="listitem"); the active step carries aria-current="step". A step is linked to its panel both ways — aria-controls from the step button to its role="region" panel, and aria-labelledby from the panel back to the step. Inactive vertical panels stay mounted for the collapse animation but are inert (and aria-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 are disabled (non-interactive) — so keyboard and screen readers skip them; with lockIndicator they 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.

On this page