Browse docs
Browse docs
A compound multi-step primitive for wizards, onboarding flows, and any UX
that breaks a longer task into a sequence of shorter surfaces. Declarative
API — the sequence is expressed as <Step> children of a <Stepper>, one
carrying its own name, label, validation contract, and content.
import { Stepper, Step } from '@dashforge/tw';
<Stepper>
<Step name="account" label="Account">...</Step>
<Step name="card" label="Card details">...</Step>
<Step name="review" label="Review">...</Step>
</Stepper><Stepper> walks its children, extracts each <Step>'s props to build
the visible sequence, and drives rendering from there. <Step> itself
renders no DOM of its own — it's a config-carrier for the parent.
pending, current, completed, invalid. Themed by the color
axis.children are mounted.
Move away and the previous panel unmounts.useStep() works from any descendant.Horizontal linear wizard with a shared footer reading useStep().
import { Button, Stack, Step, Stepper, TextField, useStep } from '@dashforge/tw';
function Wizard() {
return (
<Stepper>
<Step name="account" label="Account" helperText="Contact details">
<Stack gap={3}>
<TextField name="name" label="Full name" />
<TextField name="email" label="Email" />
</Stack>
</Step>
<Step name="card" label="Card details" helperText="Payment info">
<Stack gap={3}>
<TextField name="card" label="Card number" />
</Stack>
</Step>
<Step name="review" label="Review">
<p>Everything looks good. Confirm to activate.</p>
</Step>
<Footer />
</Stepper>
);
}
function Footer() {
const step = useStep();
return (
<Stack direction="row" justify="between">
<Button variant="outline" onClick={step.goBack} disabled={step.isFirstStep}>
Back
</Button>
<Button onClick={() => step.goNext()}>
{step.isLastStep ? 'Confirm' : 'Next'}
</Button>
</Stack>
);
}orientation="vertical" stacks the strip on the left. Useful when the
sequence is long enough that a horizontal strip would crowd. Also shows
initialStep="contract" — the wizard resumes on step 3, marking every
step before it as visited so the user can jump back through them.
You deferred card activation at onboarding. Read the contract and confirm to complete activation.
I have read and accept the deferred activation contract.
import { Stepper, Step } from '@dashforge/tw';
<Stepper orientation="vertical" initialStep="contract">
<Step name="account" label="Account" helperText="Verified" />
<Step name="card" label="Card details" helperText="Saved" />
<Step name="contract" label="Contract" helperText="Sign to activate" />
<Step name="newsletter" label="Newsletter" optional helperText="Marketing" />
<Step name="review" label="Review" />
<VerticalFooter />
</Stepper>
// initialStep="contract" resumes the user mid-flow AND marks
// account/card as visited so allowJumpTo="visited" lets them jump back.visibleWhenvisibleWhen is a reactive predicate closure — the SAME contract used
by every other Dashforge primitive. When a step's predicate returns
false, that step is dropped from the visible sequence: goNext() and
goBack() walk right past it, and it disappears from the strip.
The closure can freely mix engine state (via the engine argument) and
component-local state / context / refs captured in the outer scope. React
re-render cascade guarantees fresh values on every evaluation — no
useMemo, no manual re-registration.
Immediate activation is done. Deferred requires signing a contract.
import { useState } from 'react';
import { RadioGroup, Step, Stepper } from '@dashforge/tw';
function Wizard() {
const [activation, setActivation] = useState('immediate');
return (
<Stepper>
<Step name="mode" label="Activation mode">
<RadioGroup
name="activation"
value={activation}
onValueChange={setActivation}
options={[
{ value: 'immediate', label: 'Immediate' },
{ value: 'deferred', label: 'Deferred (requires contract)' },
]}
/>
</Step>
{/* visibleWhen closure captures the outer React state.
React's re-render cascade guarantees fresh reads on every render;
no DashForm or useMemo needed. */}
<Step
name="contract"
label="Contract"
visibleWhen={() => activation === 'deferred'}
>
...contract UI...
</Step>
<Step name="review" label="Review">...</Step>
</Stepper>
);
}fields + isValidEach <Step> can declare fields — a list of RHF field names to
validate on goNext(). The Stepper calls bridge.trigger(fields)
under the hood: if any field is invalid, the transition is blocked, the
step turns invalid (red dot + alert-triangle icon), and an entry lands
on useStep().errors.
isValid is a custom async callback with AND semantics against
fields — both must pass for the step to advance. Use it for rules
that can't be expressed as RHF field validators (server checks,
cross-field predicates that don't fit a resolver).
onStepInvalid fires on each blocked attempt with { step, index, reason, errors } so you can wire side-effects like scrolling to the
first bad field or emitting a Snackbar toast.
import { DashForm } from '@dashforge/forms';
import { Alert, Link, Step, Stepper, TextField, useStep } from '@dashforge/tw';
<DashForm defaultValues={{ email: '', phone: '', card: '' }} onSubmit={activate}>
<Stepper>
<Step
name="account"
label="Account"
fields={['email', 'phone']} // ← RHF-triggered on goNext
>
<TextField name="email" label="Email" rules={{ required: '…', pattern: {…} }} />
<TextField name="phone" label="Phone" rules={{ required: '…' }} />
</Step>
<Step name="card" label="Card" fields={['card']}>
<TextField name="card" label="Card number" rules={{ required: '…' }} />
</Step>
<Step name="review" label="Review">
<ReviewPanel />
</Step>
</Stepper>
</DashForm>
// The Review panel reads useStep().errors and renders a list of
// clickable Links that call goToStep(name) — the canonical error-
// routing pattern.
function ReviewPanel() {
const step = useStep();
if (step.errors.length === 0) return <Alert color="success" title="All good" />;
return (
<Alert color="danger" title={`Fix ${step.errors.length} issue(s)`}>
{step.errors.map((e) => (
<Link key={e.step} onClick={() => step.goToStep(e.step)}>
Fix errors in: {e.label}
</Link>
))}
</Alert>
);
}When goNext() blocks, the strip's indicator turns invalid (red dot +
alert-triangle icon). That's the low-friction default — it tells the user
"something in this step is wrong" without dumping error copy on them.
When you want the WHY of the failure inside the step, there are three
complementary surfaces — pick by scope.
TextField, NumberField, Textarea, RadioGroup, Autocomplete, Slider, and
DatePicker all auto-render their own error text below the control via
bridge.getError(name). Just declare rules on the field and the error
appears the moment the user touches (blurs) an invalid field, or after
the form is submitted.
<TextField
name="email"
label="Email"
required
rules={{
required: 'Email is required',
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Enter a valid email' },
}}
/>Note: per Dashforge's "don't yell at users" policy, errors on fields the
user hasn't touched yet are suppressed. bridge.trigger(fields) populates
the errors in RHF state, but the per-field TextField won't display them
until the field is touched OR the form is submitted. Use the in-step
Alert (below) when you need the WHY of a failure to surface WITHOUT
touching every field first.
Read useStep().errors inside the step content, filter to the current
step, render an Alert. Fires as soon as goNext blocks — no per-field
interaction needed. The ErrorRouting demo above uses exactly this pattern
via a small <StepErrorSummary /> component:
function StepErrorSummary() {
const step = useStep();
const stepError = step.errors.find((e) => e.step === step.currentStep);
if (!stepError) return null;
return (
<Alert severity="danger">
<p>Fix the following before continuing</p>
{stepError.reason === 'fields' && stepError.errors ? (
<ul>
{Object.entries(stepError.errors).map(([name, err]) => (
<li key={name}>
<strong>{name}</strong>: {(err as { message?: string })?.message ?? 'invalid'}
</li>
))}
</ul>
) : (
<p>Custom validation for this step did not pass.</p>
)}
</Alert>
);
}The errors[i].errors map carries the RHF field-error dictionary for
that step's fields. When the step failed via isValid instead of
fields, errors[i].reason === 'isValid' and errors[i].errors is
null — render a custom message from the closure over your app state.
The Review step (last in the sequence) can list ALL errors across every
step + link back to each one via goToStep(name). This is the pattern
shown in the mockup: the user attempted to submit, some earlier step
failed, and the Review page becomes the single actionable place to fix
everything. See the ReviewPanel implementation inside the ErrorRouting
demo's source (Show code).
onStepInvalidFor toasts, focus management, or analytics, wire the callback:
<Stepper
onStepInvalid={({ step, index, reason, errors }) => {
snackbar.error(`Step ${index + 1}: fix ${Object.keys(errors ?? {}).length} field(s)`);
// Or: focus the first failed field, scroll to top, etc.
}}
>
...
</Stepper>Pick the surface(s) by scope: 1 for the individual field, 2 for the current step, 3 for the whole flow, 4 for global side-effects. Layer them freely — they're independent.
useStep() hookuseStep() returns the Stepper's live navigation API + state. Available
to any component rendered inside a <Stepper> — typically a shared
footer, the current step's content, or a review-step error alert.
interface UseStepReturn {
currentStep: string;
currentStepIndex: number;
visibleSteps: Array<{ name: string; label: ReactNode }>;
visitedSteps: Set<string>;
errors: StepError[];
goNext: () => Promise<boolean>;
goBack: () => void;
goToStep: (name: string) => void;
reset: () => void;
isFirstStep: boolean;
isLastStep: boolean;
canGoNext: boolean;
}The canonical error-routing pattern (Review step with clickable list)
reads errors + calls goToStep(name) — see the ErrorRouting demo
above. canGoNext is a SYNC readout of the current step's validity
based on bridge.getError(field); safe to bind to a Button's disabled
prop.
Configure <Stepper> defaults application-wide via Option C.
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Stepper: {
defaults: {
color: 'primary',
size: 'md',
orientation: 'horizontal',
labelPlacement: 'end',
},
},
},
});Configurable axes (StepperVariantProps):
| Axis | Type | Notes |
|---|---|---|
color | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'neutral' | Applied to indicator + connector fills. neutral auto-inverts via the preset CSS-var swap. |
size | 'sm' | 'md' | 'lg' | Indicator diameter + label typography. |
orientation | 'horizontal' | 'vertical' | Strip layout. |
labelPlacement | 'end' | 'below' | Horizontal only — labels next to (end) or below (below) the indicator. Ignored when orientation='vertical'. |
Precedence chain (lowest → highest):
defaultVariants (color='primary', size='md', orientation='horizontal', labelPlacement='end').theme.components.Stepper.defaults.sx — appended to the root class chain via tailwind-merge.Same chain applies to slotProps (theme slotProps → instance slotProps).
<Stepper> props| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | <Step> children. Non-<Step> children are dev-warned and ignored — the parent only recognizes its own compound child type. |
access | AccessRequirement | — | RBAC gate for the whole Stepper. |
allowJumpTo | 'visited' | 'none' | 'visited' | Controls the goToStep (indicator-click) affordance: - 'visited' (default) — the user can jump only to steps they've already visited. Forward jumps are blocked because they'd bypass validation. - 'none' — no jumping; navigation is strictly goNext / goBack. |
color | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'neutral' | 'primary' | Colour intent applied to the indicator + connector fills. |
initialStep | string | — | Name of the step to render on mount. Every step declared BEFORE initialStep in the visible sequence is marked as visited (so allowJumpTo="visited" allows the user to walk back through them).Common uses: resuming an interrupted wizard from persistent state, deep-linking to a specific step from a URL query. If the name doesn't match any visible step, falls back to the first visible step and dev-warns. |
labelPlacement | 'below' | 'end' | 'end' | end (label next to indicator, default) or below (label under the indicator, MUI alternativeLabel semantics). Horizontal only — ignored on orientation='vertical'. |
name | string | — | Optional identifier — used for logging / test IDs. |
onComplete | () => void | — | Fires when the user advances past the LAST visible step via goNext(). If this Stepper is wrapped in a <DashForm> and onComplete is omitted, the DashForm's own submit handler runs naturally on the consumer's Next button (typical pattern: <Button type="submit">Finish</Button> on the last step). Optional. |
onStepChange | ( prev: string, next: string, reason: 'next' | 'back' | 'jump' ) => void | — | Fires on every successful transition — moving forward, moving back, or jumping. Receives the previous step name, next step name, and the direction. |
onStepInvalid | (payload: OnStepInvalidPayload) => void | — | Fires when goNext() blocks because validation failed. Useful for side-effects like scrolling the first invalid field into view or emitting a Snackbar toast. |
orientation | 'vertical' | 'horizontal' | 'horizontal' | horizontal (default) or vertical strip layout. |
size | 'md' | 'sm' | 'lg' | 'md' | Density knob (indicator diameter + label size). |
slotProps | StepperSlotProps | — | Per-slot overrides. |
sx | string | — | Utility-class shortcut for slotProps.root.className. |
testId | string | — | data-testid on the root wrapper. |
visibleWhen | (engine: Engine) => boolean | — | Reactive visibility predicate for the whole Stepper. |
<Step> props| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Short label rendered next to (or below) the indicator dot. |
name | string | — | Unique step key. Also used by visitedSteps, goToStep, errors, and initialStep. Required — every step must have a distinct name. |
access | AccessRequirement | — | RBAC gate — hides or disables the step. |
children | ReactNode | — | The step's content — rendered inside the content slot when active. |
fields | string[] | — | List of RHF field names to validate on goNext(). Runs bridge.trigger(fields) and blocks the transition if any field fails. Combined with isValid via AND semantics: both must pass for the step to be considered valid.Requires a wrapping <DashForm> — outside a form context this prop is a no-op (dev-warn). |
helperText | ReactNode | — | Secondary line rendered under the label. Optional. Common uses: "2 min", "Enter card info", "Optional". |
icon | ReactNode | — | Custom icon rendered inside the indicator dot for the pending and current states. Not shown when completed (a check mark takes over) or invalid (an alert-triangle icon takes over).MUI-parity — an escape hatch for domain-specific dots ( <Eye /> on a Review step, <Cog /> on a Settings step). |
isValid | () => boolean | Promise<boolean> | — | Custom validation callback with AND-semantics against fields. Runs AFTER fields (only if fields passes). Return false (sync or async) to block the transition.Use for domain rules that can't be expressed as RHF field validators (e.g., "at least one item in the cart", "the payment gateway acknowledged the pre-auth"). |
optional | boolean | ReactNode | — | Marks the step as optional. true renders a default "Optional" italic caption below the label; passing a ReactNode substitutes the caption (optional={<span>Skip if remote</span>}). MUI-parity.NOTE: optional is display-only — it does NOT skip validation. A step that should be skippable at the flow level should use visibleWhen to hide itself, not optional. |
visibleWhen | (engine: Engine) => boolean | — | Reactive visibility predicate — catalog-uniform closure. Steps whose predicate returns false are excluded from visibleSteps, skipped by goNext() / goBack(), and hidden from the indicator strip. This is the primary hook for dynamic step composition (e.g., a "Legal address" step that only shows for EU customers). |
| Slot | Purpose |
|---|---|
root | Outer wrapper (strip + content + passthrough are direct children). sx = shortcut for slotProps.root.className. |
strip | The indicator strip. Flex row (horizontal) or column (vertical). |
step | Each rendered step item (indicator + label group). Carries data-state and data-name for CSS hooks. |
indicator | The round dot carrying the step number, check, alert, or custom icon. |
labelGroup | Vertical stack holding label + helperText + optional caption. |
label | The step's short title. |
helperText | Secondary line below the label. |
optionalTag | The italic "Optional" (or consumer-supplied node) tag. |
connector | The line drawn between two adjacent indicators. data-completed="true" when the step BEFORE it is completed. |
content | The panel that renders the current step's children. |
footer | Reserved (currently unused — the passthrough children pattern replaces it). |
<Step> renders no DOM. The parent
walks its children, reads each <Step>'s props, and drives rendering.
This mirrors the MUI Tabs / Chakra Tabs / most-of-the-industry pattern.
If you wrap steps in an arbitrary <div>, the parent won't see them
and will skip them — wrap in a <Fragment> if you need a JSX group.visibleWhen closure semantics: same as every other Dashforge
primitive. The predicate CAN mix engine state and component-local
state; React's re-render cascade evaluates it every render.fields requires a <DashForm> (or bridge with trigger):
outside a form context, fields is a no-op and dev-warns. The bridge
extension trigger shipped in @dashforge/[email protected] — older
versions silently skip the validation.fields + isValid are AND semantics: isValid only runs if
fields passed. To OR them, express the branch inside a single
isValid callback.initialStep semantic is BY NAME, not index: safer against
reordering + reads better in URL query params (?step=review beats
?step=2).<Step> children are passthrough: place a shared footer,
review summary, or DevTools panel as a direct child of <Stepper>;
they render below the content, inside the useStep() context.goNext() past the last step: fires onComplete(). If wrapped in
a <DashForm> and onComplete is omitted, the natural pattern is to
wire the final "Confirm" button as <Button type="submit"> — RHF
handles the submit.canGoNext is a SYNC readout: derived from bridge.getError().
It doesn't run isValid (that could be async), so bind it to Button
disabled state to hint at validity, but rely on goNext()'s own
return value for the actual gate.useStep().errors array + goToStep(name) are
the primitives — the ReviewPanel demo above shows the canonical
clickable-list pattern.role="list" + role="listitem" per step.
Each indicator carries a descriptive aria-label ("Step 1: Account").
Custom footer buttons should carry semantic labels ("Next", "Back",
"Confirm activation") — the Stepper stays out of that layer.