@dev-nat/react-stepper
v1.0.1
Published
Headless React stepper component for multi-step forms and wizards
Maintainers
Readme
@dev-nat/react-stepper
A tiny, framework-light React context that owns one thing: step navigation.
It has no idea what's inside your steps — pair it with react-hook-form, zod,
plain useState, or nothing at all. The form state lives wherever you want;
the stepper just moves forward, backward, or jumps to a specific step.
Features
- Zero state coupling — steps are opaque React elements; the stepper never reads or writes form data.
- Isolated per flow — every
createStepper()call gets its own contexts, so multiple steppers on one page (checkout, profile setup, wizard) never interfere. - Fine-grained subscriptions — state and actions live in separate contexts, so components that only navigate don't re-render on step changes.
- Stable, safe callbacks —
nextStep/prevStepclamp via functional updates, so double-clicks can't push the index out of bounds.
Install
pnpm add @dev-nat/react-stepperPeer dependencies: react >= 18 (required) and @types/react >= 18
(optional — only used for typing, not at runtime).
Quick start
'use client'
import { createStepper } from '@dev-nat/react-stepper'
const { useStepperState, StepperScopeProvider } = createStepper([
{ id: 'personal', component: <PersonalInfoStep /> },
{ id: 'address', component: <AddressStep /> },
{ id: 'payment', component: <PaymentStep /> },
])
function StepBody() {
const { currentStep } = useStepperState()
return <main>{currentStep.component}</main>
}
export function CheckoutFlow() {
return (
<StepperScopeProvider>
<StepBody />
</StepperScopeProvider>
)
}
createSteppermust be called at module top level, once per flow. Every call builds fresh contexts + a provider component, so calling it inside a component would recreate (and reset) the whole stepper on every render.
API
createStepper(config)
Takes a non-empty array of StepperConfig and returns:
{
StepperScopeProvider, // context provider component
useStepper, // combined state + actions (see below)
useStepperState, // subscribe to currentStep / progress / booleans
useStepperActions, // subscribe to steps / nextStep / prevStep / goTo
}Throws Stepper config must at least have 1 step when passed an empty array.
StepperConfig
type StepperConfig = {
id: string // unique, used by goTo
component: ReactElement // any element — forms, markup, anything
}StepperScopeProvider
Wraps the component tree. All useStepper* hooks must be called from
descendants of this provider.
<StepperScopeProvider>
{/* your steps, nav, progress UI */}
</StepperScopeProvider>Hooks
All three hooks throw a descriptive error if called outside the provider.
useStepperState() — re-renders on every step transition:
{
currentStep: StepperConfig
isLast: boolean
isFirst: boolean
progress: { value: number; total: number } // value is 1-based
}useStepperActions() — stable object, created once:
{
steps: StepperConfig[]
nextStep: () => void
prevStep: () => void
goTo: (id: string) => void // throws 'Step ID not found' for unknown ids
}useStepper() — convenience combination of the two above, returns the
full Stepper object. Prefer the split hooks when a component
only needs one half.
Stepper
type Stepper = {
steps: StepperConfig[]
currentStep: StepperConfig
isLast: boolean
isFirst: boolean
nextStep: () => void
prevStep: () => void
goTo: (id: string) => void
progress: { value: number; total: number }
}Full example
Checkout flow with progress bar, clickable step indicators, and prev/next navigation:
'use client'
import { createStepper } from '@dev-nat/react-stepper'
const { useStepperState, useStepperActions, StepperScopeProvider } =
createStepper([
{ id: 'personal', component: <PersonalInfoStep /> },
{ id: 'address', component: <AddressStep /> },
{ id: 'payment', component: <PaymentStep /> },
])
function ProgressBar() {
const { progress } = useStepperState()
const percent = Math.round((progress.value / progress.total) * 100)
return (
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={progress.total}
aria-valuenow={progress.value}
style={{ height: 8, background: '#e5e7eb', borderRadius: 999 }}
>
<div
style={{
height: '100%',
width: `${percent}%`,
background: '#2563eb',
borderRadius: 999,
}}
/>
</div>
)
}
function StepIndicators() {
const { steps, goTo } = useStepperActions()
const { progress } = useStepperState()
const currentIndex = progress.value - 1
return (
<ol style={{ display: 'flex', gap: 8, listStyle: 'none', padding: 0 }}>
{steps.map((step, index) => (
<li key={step.id}>
<button
type="button"
onClick={() => goTo(step.id)}
aria-current={index === currentIndex ? 'step' : undefined}
>
{index + 1}
</button>
</li>
))}
</ol>
)
}
function StepActions() {
const { isFirst, isLast } = useStepperState()
const { nextStep, prevStep } = useStepperActions()
return (
<div>
<button type="button" onClick={prevStep} disabled={isFirst}>
Previous
</button>
<button type="button" onClick={nextStep} disabled={isLast}>
{isLast ? 'Done' : 'Next'}
</button>
</div>
)
}
function StepBody() {
const { currentStep } = useStepperState()
return <main>{currentStep.component}</main>
}
export function CheckoutStepper() {
return (
<StepperScopeProvider>
<StepIndicators />
<ProgressBar />
<StepBody />
<StepActions />
</StepperScopeProvider>
)
}Multiple steppers
Each createStepper() call is fully isolated:
const checkout = createStepper(checkoutSteps)
const profileSetup = createStepper(profileSteps)
export function SettingsPage() {
return (
<profileSetup.StepperScopeProvider>
<checkout.StepperScopeProvider>
{/* two independent wizards, two independent currentIndexes */}
</checkout.StepperScopeProvider>
</profileSetup.StepperScopeProvider>
)
}Semantics & errors
| Situation | Behavior |
| --- | --- |
| createStepper([]) | Throws Stepper config must at least have 1 step |
| goTo('unknown') | Throws Step ID not found |
| nextStep() on last step | No-op (index is clamped) |
| prevStep() on first step | No-op (index is clamped) |
| useStepper*() outside provider | Throws ... must be used within a StepperScopeProvider |
| progress.value | 1-based (1..total); currentIndex stays 0-based |
Performance
- Split contexts — state and actions are separate providers; consumers using only actions never re-render on step changes.
- Stable callbacks —
nextStep,prevStep, andgoTonever change identity, so they're safe to use inuseEffect/useMemodeps. - O(1)
goTo— step ids are indexed in aMapat creation time.
