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 preserved as passthrough (footer / review summary) and rendered after the content panel, still inside the Stepper context. Stray DOM (bare <div>, text) is ignored + dev-warned. |
name | string | — | Optional identifier — used for logging / test IDs. |
initialStep | string | — | Name of the step to render on mount. Every step declared BEFORE it is pre-marked as visited so allowJumpTo="visited" allows walking back through them. |
allowJumpTo | 'visited' | 'none' | 'visited' | Controls the indicator-click affordance. 'visited' allows jumping to already-visited steps; 'none' disables jumping entirely. |
onComplete | () => void | — | Fires when the user advances past the LAST visible step. |
onStepChange | (prev, next, reason: 'next' | 'back' | 'jump') => void | — | Fires on every successful transition. |
onStepInvalid | (payload: { step, index, reason, errors }) => void | — | Fires when goNext() blocks. Useful for scroll-to-first-error / toast side effects. |
color | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'neutral' | 'primary' | Indicator + connector color. |
size | 'sm' | 'md' | 'lg' | 'md' | Density knob. |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Strip layout. |
labelPlacement | 'end' | 'below' | 'end' | Horizontal only. 'below' mirrors MUI's alternativeLabel. |
visibleWhen | (engine: Engine) => boolean | — | Reactive predicate hiding the whole Stepper. |
access | AccessRequirement | — | RBAC gate. |
sx | string | — | Root className shortcut. |
slotProps | StepperSlotProps | — | Per-slot className overrides. |
testId | string | — | data-testid on the root wrapper. |
<Step> props| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | Unique step key. |
label | ReactNode | — | Short title shown next to the indicator. |
helperText | ReactNode | — | Secondary line under the label. |
icon | ReactNode | — | Custom icon for the indicator (overridden by check/alert on completed / invalid). |
optional | boolean | ReactNode | — | Renders an italic caption below the label. Display-only — does NOT skip validation. |
fields | string[] | — | RHF field names validated via bridge.trigger() on goNext(). |
isValid | () => boolean | Promise<boolean> | — | Custom async validation with AND semantics against fields. |
visibleWhen | (engine: Engine) => boolean | — | Reactive predicate excluding the step from the sequence. |
access | AccessRequirement | — | RBAC gate — hides or disables the step. |
children | ReactNode | — | Rendered inside the content panel when this step is current. |
| 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.