Stepper
Free7 variants — six wizard styles (default, pill, minimal, icon, dot, line) plus a non-interactive tracker — all free, plus horizontal/vertical orientation. Linear wizard mode, guard rules, and telemetry are available in the Pro package.
import { SvStepperComponent } from '@styloviz/stepper';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
All 7 variants (free)
<sv-stepper [items]="steps" variant="line" orientation="vertical" [activeIndex]="step" />
Vertical linear wizard (Pro)
<sv-stepper-pro [items]="steps" orientation="vertical" [linear]="true" [interactive]="true" [showDescriptions]="true" [activeIndex]="step" (stepChange)="step = $event" (stepBlocked)="onBlocked($event)" />
Order status tracker (placed → delivered)
variant="tracker" renders a horizontal fulfilment tracker: completed stages fill the rail, the current stage is ringed, each with an icon and timestamp.
<sv-stepper variant="tracker" [items]="orderSteps" [activeIndex]="3" [interactive]="false" />
When to use
- Job: workflow state — renders step buttons and emits step events; it never navigates the page (compare all seven in the “Choosing a navigation pattern” guide under Guides).
- Guiding users through a sequential, multi-step process (checkout, onboarding, wizards).
- You want progress indication, and — on the Pro package — per-step validation gating.
- For non-sequential views, use Tabs; for an overlay walkthrough, a Product Tour.
Async states
- loading
- error
- retry
Saved state
- Stored kinds
- sv-stepper/resume
What you will find under your own storage key. You choose the key; the blob names its own
kindand schemaversion, so a component never reads state another one wrote.- Reading it back
- Restore through the component's own
restore…helper rather than casting the parsed JSON. A blob from an older release is migrated where it can be and refused with a reason where it cannot — so an upgrade never silently applies last month's shape to this month's fields.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| items | StepperItem[] | [] | The steps to render (label, optional description, state). |
| activeIndex | number (two-way) | 0 | 0-based active step index. Supports two-way binding via `[(activeIndex)]`. |
| orientation | StepperOrientation | 'horizontal' | Layout: horizontal or vertical. |
| variant | StepperVariant | 'default' | Visual variant — and with it, whether this is a wizard or a tracker. `default`, `pill`, `minimal`, `icon`, `dot` and `line` are WIZARD styles: every step is a `<button>`, and the linear gate, keyboard, `stepClick` and the unsaved prompt are all live. `'tracker'` is a PROGRESS DISPLAY (an order-status strip): it renders a non-interactive `<ol>` with no buttons, so nothing in it can be clicked, focused or arrowed through and `stepClick` / `stepChangeRequest` never fire from it. Two axes share this one input — look and interactivity. Choose `tracker` to show where something IS, not to let the user move it; for a wizard's look without its navigation, keep a wizard style and set `interactive=false` instead. |
| size | StepperSize | 'md' | Step size: sm, md or lg. |
| mode | StepperMode | 'standard' | Navigation mode: standard sequence, or branch (conditional paths via `activeBranch`). |
| interactive | boolean | true | Whether users can click steps to navigate. |
| showDescriptions | boolean | true | Show step descriptions under labels. |
| showConnectors | boolean | true | Render connectors between steps. |
| enableHotkeys | boolean | false | Enables advanced keyboard shortcuts for rapid step navigation. |
| activeBranch | string | null (two-way) | null | Active branch key used in `mode='branch'`. Supports two-way binding. |
| customClass | string | '' | Extra CSS classes for the root element. |
| urlKey | string | null | null | Put the current step's **id** in the URL under this key, so a step can be shared and the back button moves one step back. Needs `provideSvNavLocation()` at the app root. |
| surfaceId | string | null | null | Names this stepper in the shared navigation-analytics stream, when a page has more than one. Ignored unless `provideSvNavTelemetry()` is wired up. |
| persistKey | string | '' | Where `localStorage` keeps this workflow's position, so a reload resumes rather than restarting. Stores the step id and branch only — never the host's form data. Empty = no persistence. |
| asyncValidation | boolean | false | Ask before moving. With this on, activating a step emits `stepChangeRequest` and nothing happens until the host calls `allow()` or `deny()` — which is what lets a wizard validate the current step against the server before letting the user leave it. |
| validatingIndex | number | -1 | Index currently being validated, or `-1`. That step shows a spinner and the stepper ignores further activations, so a double-click cannot start two validations. |
| revisitPolicy | StepperRevisitPolicy | 'preserve' | What returning to an earlier step does to the steps after it. See StepperRevisitPolicy. `'preserve'` IS TODAY'S BEHAVIOUR, and that is why it is the default: before this input there was no defined effect at all, so anything else would start invalidating work in wizards that never asked for it. |
| invalidatesOnRevisit | ((context: StepperRevisitContext) => readonly string[]) | null | null | With `revisitPolicy="custom"`, the step ids a revisit invalidates. Ids that are not visible steps are ignored. ```ts protected readonly recomputeTotals = (c: StepperRevisitContext) => c.step.id === 'billing' ? ['shipping', 'confirm']: []; ``` |
| dirty | boolean | false | Is there unsaved work in the step the user is on? A WIZARD STEP'S FORM COULD BE ABANDONED BY ONE CLICK ON ANOTHER STEP, with no warning and no undo. Set this and the stepper ASKS instead of moving — from `createCloseGuard`, the same primitive Modal, Drawer and Confirm Dialog use, so the prompt behaves identically in all four and the order of the questions is decided once (`docs/close-policy-adr.md`). |
| canLeaveStep | ((reason: StepperLeaveReason) => boolean | void) | null | null | The host's own veto on leaving a step, consulted **before** the unsaved check. Return `false` to refuse; return nothing to have no opinion. A veto is silent — it does NOT raise the unsaved-changes prompt, because the host refused for its own reasons and owns its own messaging. That ordering is the primitive's, not this component's. |
| showUnsavedPrompt | boolean | true | Render the built-in unsaved-changes prompt. Turn it off to drive your own dialog from `promptingDiscard()` / `discardChanges()` / `keepEditing()`. |
| unsavedTitle | string | 'Leave this step?' | Heading of the unsaved-changes prompt. |
| unsavedMessage | string | 'This step has changes that have not be… | Body of the unsaved-changes prompt. |
| discardLabel | string | 'Discard' | Label of the button that leaves and loses the changes. |
| keepEditingLabel | string | 'Keep editing' | Label of the button that stays on the step. |
| loading | boolean | false | True while the step definition is being fetched. A wizard defined server-side — onboarding flows, dynamic forms — arrives asynchronously. |
| error | SvDataError | string | null | null | Failure loading the step definition. Rendered in place of the content, with a retry. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| retry | void | The host should re-run the failed load. |
| stepChange | StepperChangeEvent | Emitted when the active step changes. |
| unresolved | SvUnresolvedValue<number> | Emitted when `activeIndex` names a step that does not exist. Carries what was asked for, what is shown, and whether `activeIndex` was corrected. |
| stepClick | { index: number; step: StepperItem } | Emitted when a step is clicked. |
| stepBlocked | StepperBlockedEvent | Emitted when navigation to a step is blocked (e.g. linear validation). |
| stepChangeRequest | StepperChangeRequest | A move the host must approve. Only emitted when `asyncValidation` is on. See StepperChangeRequest. |
| branchChange | StepperBranchChangeEvent | Emitted when the active branch changes (branch mode). |
| stepTransition | { readonly step: StepperItem; readonly index: number; readonly transition: StepperTransition; readonly from: StepperStepLifecycle; readonly to: StepperStepLifecycle; } | A named lifecycle transition just happened. The transition, not just the destination. `enter` and `reopen` both land in `active`, and a host that invalidates later work needs to know which of the two it was; `stepChange` cannot tell it. |
| stepsInvalidated | StepperInvalidationEvent | The revisit policy says these steps are no longer done. See StepperInvalidationEvent. |
| flowStatusChange | StepperFlowStatus | `complete` and `valid`, reported separately — see StepperFlowStatus. |
| discarded | StepperLeaveReason | The user chose to abandon unsaved work; the move follows. |
| leaveCancelled | StepperLeaveReason | The user chose to stay on the step. Nothing moved. |
| activeIndexChange | number | Emits the new `activeIndex` whenever it changes — the output half of `[(activeIndex)]`. |
| activeBranchChange | string | null | Emits the new `activeBranch` whenever it changes — the output half of `[(activeBranch)]`. |
<sv-stepper-pro>
Also in this family — SvStepperProComponent from @styloviz-pro/stepper.
Async states
- loading
- error
- retry
Saved state
- Stored kinds
- sv-stepper-pro/resume
What you will find under your own storage key. You choose the key; the blob names its own
kindand schemaversion, so a component never reads state another one wrote.- Reading it back
- Restore through the component's own
restore…helper rather than casting the parsed JSON. A blob from an older release is migrated where it can be and refused with a reason where it cannot — so an upgrade never silently applies last month's shape to this month's fields.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| items | StepperItem[] | [] | The steps, in order. In `mode="branch"` a step with a `branch` key is shown only while that branch is active. |
| activeIndex | number (two-way) | 0 | 0-based active step index. Supports two-way binding via `[(activeIndex)]`. |
| orientation | StepperOrientation | 'horizontal' | Lay the steps out in a row (`horizontal`) or a column (`vertical`). |
| variant | StepperVariant | 'default' | Visual variant — and with it, whether this is a wizard or a tracker. `default`, `pill`, `minimal`, `icon`, `dot` and `line` are WIZARD styles: every step is a `<button>`, and the linear gate, keyboard, `stepClick` and the unsaved prompt are all live. `'tracker'` is a PROGRESS DISPLAY (an order-status strip): it renders a non-interactive `<ol>` with no buttons, so nothing in it can be clicked, focused or arrowed through and `stepClick` / `stepChangeRequest` never fire from it. Two axes share this one input — look and interactivity. Choose `tracker` to show where something IS, not to let the user move it; for a wizard's look without its navigation, keep a wizard style and set `interactive=false` instead. |
| size | StepperSize | 'md' | Size of the step indicators, labels and descriptions. |
| mode | StepperMode | 'standard' | `branch` enables conditional workflows: steps are filtered by `activeBranch`, and a step's `selectsBranch` switches it. |
| linear | boolean | false | If true, blocked future steps cannot be selected unless prior steps are complete. |
| interactive | boolean | true | Whether users can click steps to navigate. |
| showDescriptions | boolean | true | Show step descriptions under labels. |
| showConnectors | boolean | true | Render connectors between steps. |
| enableHotkeys | boolean | false | Enables advanced keyboard shortcuts for rapid step navigation. |
| guardRules | Record<string, string[]> | {} | Optional guard map where each key is a target step id and the value lists step ids that must be complete before selecting that target. |
| activeBranch | string | null (two-way) | null | Active branch key used in `mode='branch'`. Supports two-way binding. |
| customClass | string | '' | Extra CSS classes added to the root element. |
| urlKey | string | null | null | Put the current step's **id** in the URL under this key, so a step can be shared and the back button moves one step back. Needs `provideSvNavLocation()` at the app root. |
| surfaceId | string | null | null | Names this stepper in the shared navigation-analytics stream, when a page has more than one. Ignored unless `provideSvNavTelemetry()` is wired up. |
| persistKey | string | '' | Where `localStorage` keeps this workflow's position, so a reload resumes rather than restarting. Stores the step id and branch only — never the host's form data. Empty = no persistence. |
| asyncValidation | boolean | false | Ask before moving. With this on, activating a step emits `stepChangeRequest` and nothing happens until the host calls `allow()` or `deny()` — which is what lets a wizard validate the current step against the server before letting the user leave it. |
| validatingIndex | number | -1 | Index currently being validated, or `-1`. That step shows a spinner and the stepper ignores further activations, so a double-click cannot start two validations. |
| revisitPolicy | StepperRevisitPolicy | 'preserve' | What returning to an earlier step does to the steps after it. See StepperRevisitPolicy. `'preserve'` IS TODAY'S BEHAVIOUR, and that is why it is the default: before this input there was no defined effect at all, so anything else would start invalidating work in wizards that never asked for it. |
| invalidatesOnRevisit | ((context: StepperRevisitContext) => readonly string[]) | null | null | With `revisitPolicy="custom"`, the step ids a revisit invalidates. Ids that are not visible steps are ignored. ```ts protected readonly recomputeTotals = (c: StepperRevisitContext) => c.step.id === 'billing' ? ['shipping', 'confirm']: []; ``` |
| dirty | boolean | false | Is there unsaved work in the step the user is on? A WIZARD STEP'S FORM COULD BE ABANDONED BY ONE CLICK ON ANOTHER STEP, with no warning and no undo. Set this and the stepper ASKS instead of moving — from `createCloseGuard`, the same primitive Modal, Drawer and Confirm Dialog use, so the prompt behaves identically in all four and the order of the questions is decided once (`docs/close-policy-adr.md`). |
| canLeaveStep | ((reason: StepperLeaveReason) => boolean | void) | null | null | The host's own veto on leaving a step, consulted **before** the unsaved check. Return `false` to refuse; return nothing to have no opinion. A veto is silent — it does NOT raise the unsaved-changes prompt, because the host refused for its own reasons and owns its own messaging. That ordering is the primitive's, not this component's. |
| showUnsavedPrompt | boolean | true | Render the built-in unsaved-changes prompt. Turn it off to drive your own dialog from `promptingDiscard()` / `discardChanges()` / `keepEditing()`. |
| unsavedTitle | string | 'Leave this step?' | Heading of the unsaved-changes prompt. |
| unsavedMessage | string | 'This step has changes that have not be… | Body of the unsaved-changes prompt. |
| discardLabel | string | 'Discard' | Label of the button that leaves and loses the changes. |
| keepEditingLabel | string | 'Keep editing' | Label of the button that stays on the step. |
| loading | boolean | false | True while the step definition is being fetched. A wizard defined server-side — onboarding flows, dynamic forms — arrives asynchronously. |
| error | SvDataError | string | null | null | Failure loading the step definition. Rendered in place of the content, with a retry. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| retry | void | The host should re-run the failed load. |
| stepChange | StepperChangeEvent | The active step changed (click, keyboard, URL or an approved `stepChangeRequest`). Carries the previous and new index and the new step. |
| unresolved | SvUnresolvedValue<number> | Emitted when `activeIndex` names a step that does not exist. Carries what was asked for, what is shown, and whether `activeIndex` was corrected. |
| stepClick | { index: number; step: StepperItem } | A step was pressed, before any gate or validation runs; fires even if the move is then blocked. Carries its index and item. |
| stepBlocked | StepperBlockedEvent | A move was refused by a disabled step, the `linear` gate, `guardRules`, or an async `deny()`. Carries the target step and the reason. |
| stepChangeRequest | StepperChangeRequest | A move the host must approve. Only emitted when `asyncValidation` is on. See StepperChangeRequest. |
| branchChange | StepperBranchChangeEvent | Activating a step with `selectsBranch` switched the active branch. Carries the new branch and the step that selected it. |
| telemetry | StepperTelemetryEvent | Analytics stream for the whole flow — emits `guard_hit`, `blocked` and `branch_switch` events with the step id, index, active branch and block reason. |
| stepTransition | { readonly step: StepperItem; readonly index: number; readonly transition: StepperTransition; readonly from: StepperStepLifecycle; readonly to: StepperStepLifecycle; } | A named lifecycle transition just happened. The transition, not just the destination. `enter` and `reopen` both land in `active`, and a host that invalidates later work needs to know which of the two it was; `stepChange` cannot tell it. |
| stepsInvalidated | StepperInvalidationEvent | The revisit policy says these steps are no longer done. See StepperInvalidationEvent. |
| flowStatusChange | StepperFlowStatus | `complete` and `valid`, reported separately — see StepperFlowStatus. |
| discarded | StepperLeaveReason | The user chose to abandon unsaved work; the move follows. |
| leaveCancelled | StepperLeaveReason | The user chose to stay on the step. Nothing moved. |
| activeIndexChange | number | Emits the new `activeIndex` whenever it changes — the output half of `[(activeIndex)]`. |
| activeBranchChange | string | null | Emits the new `activeBranch` whenever it changes — the output half of `[(activeBranch)]`. |
Wire it to your API
This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.
Template
Bound to the names this component actually exposes.
<sv-stepper [items]="data.items()" [loading]="data.loading()" [error]="data.error()" (retry)="data.reload()" />
Host class
Drive the three async inputs from your own fetch.
// This component reports its state but does not emit a query, so drive // it from your own fetch and bind the three async inputs. readonly items = signal<Row[]>([]); readonly loading = signal(false); readonly error = signal<SvDataError | null>(null); async load(): Promise<void> { this.loading.set(true); this.error.set(null); try { this.items.set(await this.api.fetchRows()); } catch (e) { this.error.set(toSvDataError(e)); } finally { this.loading.set(false); } }
- This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `items`, `loading` and `error`.
- Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.
Full walkthrough: Connecting to a backend.
Customization & theming
- Six visual variants — default, pill, minimal, icon, dot, line — all included in the free package.
- Horizontal or vertical orientation.
- Pro adds: linear wizard mode with per-step validation, guard rules, branch telemetry, and roving-tabindex hotkeys.
Best practices
- Do: Use `[linear]="true"` (Pro) when later steps depend on earlier ones being valid.
- Do: Clearly mark completed, current and blocked steps.
- Tip: Switch to vertical orientation when step descriptions are long.
Accessibility
- Rendered as a labelled `list` of `listitem`s; each step is a real `<button>` (announced as a button) that exposes completed/current/disabled state via `aria-current="step"` and `aria-disabled`.
- Keyboard reachable via a roving tabindex: the current step is tabbable and Arrow/Home/End move between steps, with optional number hotkeys (`enableHotkeys`).
Feedback on Stepper
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.