Stepper
Presentational multi-step progress chrome with Ant statuses and ReUI-shaped compound parts.
Stepper is presentational multi-step progress chrome: connectors, finish checks, horizontal/vertical layout, and Ant-style wait / process / finish / error statuses. It follows Ant Design Steps status vocabulary with a ReUI-shaped compound API. Hosts own navigation state; Stepper does not validate forms or submit.
Presentational Only
Stepper has no form validate or submit. For Formily multi-step wizards, use Steps Form.
When To Use
- Show discrete step progress for wizards, onboarding, or checkout-style flows with known steps.
- Prefer Stepper over Progress when the UI needs named steps, connectors, and finish checks — not a percent bar.
- Drive
value/onValueChangefrom the host (Back / Next, or click completed steps). - Use root
status="error"to mark the current step when validation failed. - For Formily multi-step submit with per-step validate, use Steps Form (consumes this chrome).
Features
| Area | Behavior |
|---|---|
| Orientation | horizontal (default) or vertical via orientation |
| Status | Derived wait / process / finish; root status can force current to error / finish / process |
| Indicator | Number (step + 1) or Lucide check when finish; destructive ring when error; optional root indicators slots |
| Loading | Item loading spins the current process indicator (not a StepStatus) |
| Connectors | StepperSeparator between items |
| Interaction | Click StepperTrigger calls onValueChange(step) unless disabled |
| A11y | Process trigger sets aria-current="step"; nav is an ordered list |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/stepper.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/stepper.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/stepper.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/stepper.jsonWith a namespace: npx shadcn@latest add @f-ui/stepper.
registryDependencies: utils. Runtime: lucide-react.
Usage
import {
STEPPER_SEPARATOR_ABSOLUTE_HORIZONTAL,
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperNav,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/f-ui/stepper/stepper";
<Stepper value={current} onValueChange={setCurrent}>
<StepperNav>
<StepperItem step={0} className="relative flex-1 items-start">
<StepperTrigger className="flex flex-col gap-2.5">
<StepperIndicator />
<StepperTitle>Basics</StepperTitle>
<StepperDescription>Account email</StepperDescription>
</StepperTrigger>
<StepperSeparator className={STEPPER_SEPARATOR_ABSOLUTE_HORIZONTAL} />
</StepperItem>
<StepperItem step={1} disabled={current < 1} className="relative flex-1 items-start">
<StepperTrigger className="flex flex-col gap-2.5">
<StepperIndicator />
<StepperTitle>Access</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
</Stepper>Examples
Horizontal Controlled
Controlled Stepper with Back / Next — ReUI title-under-indicator layout with connectors pinned through the circles.
"use client";
import { useState } from "react";
import {
STEPPER_SEPARATOR_ABSOLUTE_HORIZONTAL,
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperNav,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/f-ui/stepper/stepper";
import { Button } from "@/components/ui/button";
const STEPS = [
{ title: "Basics", description: "Account email" },
{ title: "Access", description: "Roles and permissions" },
{ title: "Review", description: "Confirm and submit" },
] as const;
export function StepperDemo() {
const [current, setCurrent] = useState(1);
return (
<div className="flex w-full max-w-md flex-col gap-8">
<Stepper value={current} onValueChange={setCurrent}>
<StepperNav>
{STEPS.map((step, index) => (
<StepperItem
key={step.title}
step={index}
className="relative flex-1 items-start"
>
<StepperTrigger className="flex flex-col gap-2.5">
<StepperIndicator />
<StepperTitle>{step.title}</StepperTitle>
<StepperDescription>{step.description}</StepperDescription>
</StepperTrigger>
{index < STEPS.length - 1 ? (
<StepperSeparator
className={STEPPER_SEPARATOR_ABSOLUTE_HORIZONTAL}
/>
) : null}
</StepperItem>
))}
</StepperNav>
</Stepper>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
disabled={current === 0}
onClick={() => setCurrent((v) => Math.max(0, v - 1))}
>
Back
</Button>
<Button
type="button"
disabled={current === STEPS.length - 1}
onClick={() => setCurrent((v) => Math.min(STEPS.length - 1, v + 1))}
>
Next
</Button>
</div>
</div>
);
}Vertical With Descriptions
Vertical orientation with title/description beside the indicator and absolute vertical connectors (ReUI c-stepper-13 pattern).
"use client";
import { useState } from "react";
import {
STEPPER_SEPARATOR_ABSOLUTE_VERTICAL,
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperNav,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/f-ui/stepper/stepper";
const STEPS = [
{
title: "Plan",
description: "Pick the workspace and owners for this rollout.",
},
{
title: "Configure",
description: "Set integrations, policies, and default roles.",
},
{
title: "Launch",
description: "Publish the change and notify stakeholders.",
},
] as const;
export function StepperVerticalDemo() {
const [current, setCurrent] = useState(1);
return (
<Stepper
value={current}
onValueChange={setCurrent}
orientation="vertical"
className="max-w-sm"
>
<StepperNav>
{STEPS.map((step, index) => (
<StepperItem
key={step.title}
step={index}
className="relative items-start not-last:flex-1"
>
<StepperTrigger className="items-start gap-2.5 pb-12 last:pb-0">
<StepperIndicator />
<div className="mt-0.5 flex flex-col gap-0.5 text-left">
<StepperTitle>{step.title}</StepperTitle>
<StepperDescription>{step.description}</StepperDescription>
</div>
</StepperTrigger>
{index < STEPS.length - 1 ? (
<StepperSeparator className={STEPPER_SEPARATOR_ABSOLUTE_VERTICAL} />
) : null}
</StepperItem>
))}
</StepperNav>
</Stepper>
);
}Error Status
Root status="error" marks the current step with a destructive indicator while past steps stay finished.
"use client";
import {
STEPPER_SEPARATOR_ABSOLUTE_HORIZONTAL,
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperNav,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/f-ui/stepper/stepper";
export function StepperErrorDemo() {
return (
<Stepper value={1} status="error" className="max-w-md">
<StepperNav>
<StepperItem step={0} className="relative flex-1 items-start">
<StepperTrigger className="flex flex-col gap-2.5">
<StepperIndicator />
<StepperTitle>Basics</StepperTitle>
<StepperDescription>Completed</StepperDescription>
</StepperTrigger>
<StepperSeparator className={STEPPER_SEPARATOR_ABSOLUTE_HORIZONTAL} />
</StepperItem>
<StepperItem step={1} className="relative flex-1 items-start">
<StepperTrigger className="flex flex-col gap-2.5">
<StepperIndicator />
<StepperTitle>Access</StepperTitle>
<StepperDescription>Fix validation errors</StepperDescription>
</StepperTrigger>
<StepperSeparator className={STEPPER_SEPARATOR_ABSOLUTE_HORIZONTAL} />
</StepperItem>
<StepperItem step={2} className="relative flex-1 items-start">
<StepperTrigger className="flex flex-col gap-2.5">
<StepperIndicator />
<StepperTitle>Review</StepperTitle>
<StepperDescription>Waiting</StepperDescription>
</StepperTrigger>
</StepperItem>
</StepperNav>
</Stepper>
);
}Loading
Item loading spins the current process indicator (ReUI overlay). Ant wait / process / finish / error are unchanged — loading is not a status.
Busy chrome vs footer
Prefer one primary busy signal. Steps Form keeps busy on footer Next/Submit (pending) and does not auto-set Stepper loading. Use chrome loading for non-form hosts or long in-step work where the progress trail is the focus.
"use client";
import { useState } from "react";
import { Check, LoaderCircle } from "lucide-react";
import {
Stepper,
StepperIndicator,
StepperItem,
StepperNav,
StepperSeparator,
StepperTrigger,
} from "@/components/f-ui/stepper/stepper";
import { Button } from "@/components/ui/button";
const STEPS = [0, 1, 2] as const;
export function StepperLoadingDemo() {
const [current, setCurrent] = useState(1);
const [loading, setLoading] = useState(true);
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Stepper
value={current}
onValueChange={setCurrent}
indicators={{
completed: <Check className="size-3.5" />,
loading: <LoaderCircle className="size-3.5 animate-spin" />,
}}
>
<StepperNav>
{STEPS.map((step) => (
<StepperItem key={step} step={step} loading={loading && step === current}>
<StepperTrigger>
<StepperIndicator />
</StepperTrigger>
{step < STEPS.length - 1 ? <StepperSeparator /> : null}
</StepperItem>
))}
</StepperNav>
</Stepper>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
disabled={current === 0}
onClick={() => setCurrent((v) => Math.max(0, v - 1))}
>
Back
</Button>
<Button
type="button"
disabled={current === STEPS.length - 1}
onClick={() => setCurrent((v) => Math.min(STEPS.length - 1, v + 1))}
>
Next
</Button>
<Button
type="button"
variant="outline"
onClick={() => setLoading((v) => !v)}
>
{loading ? "Stop loading" : "Start loading"}
</Button>
</div>
</div>
);
}Composition
Stepper
└── StepperNav (ol)
└── StepperItem (li, relative flex-1 items-start)
├── StepperTrigger (flex-col gap-2.5)
│ ├── StepperIndicator
│ ├── StepperTitle
│ └── StepperDescription?
└── StepperSeparator? (absolute horizontal; omit on last)For inline titles (title beside indicator), use ReUI c-stepper-9: StepperTrigger className="gap-1.5" and a normal flex separator (md:mx-2.5) — do not use the absolute helper.
API Reference
Stepper
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | 0-based current step (controlled with onValueChange). |
defaultValue | number | 0 | Initial step when uncontrolled. |
onValueChange | (value: number) => void | — | Called when a non-disabled trigger is clicked. |
orientation | "horizontal" | "vertical" | "horizontal" | Layout direction; sets data-orientation. |
status | "process" | "error" | "finish" | — | Overrides derived status for the current step. |
indicators | StepperIndicators | {} | Optional slots: active, completed, inactive, loading. Defaults use check / LoaderCircle when unset. |
className | string | — | Merged onto the root. |
children | ReactNode | — | Typically a StepperNav tree. |
StepperItem
| Prop | Type | Default | Description |
|---|---|---|---|
step | number | — | 0-based step index (required). |
status | "wait" | "process" | "finish" | "error" | derived | Override derived status for this item. |
loading | boolean | false | When true on the current process item, shows the loading indicator and data-loading. Ignored on non-current / non-process; error wins. |
disabled | boolean | false | Disables the trigger; click does not change value. |
className | string | — | Merged onto the list item. |
children | ReactNode | — | Trigger, separator, and label parts. |
Parts
| Export | Role |
|---|---|
StepperNav | Ordered list track (ol) |
StepperTrigger | Clickable control; aria-current="step" when process |
StepperIndicator | Circle with number, check when finish, or spinner when loading |
StepperSeparator | Connector line between items |
StepperTitle | Step title |
StepperDescription | Optional muted description |