f-ui
Components

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 / onValueChange from 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

AreaBehavior
Orientationhorizontal (default) or vertical via orientation
StatusDerived wait / process / finish; root status can force current to error / finish / process
IndicatorNumber (step + 1) or Lucide check when finish; destructive ring when error; optional root indicators slots
LoadingItem loading spins the current process indicator (not a StepStatus)
ConnectorsStepperSeparator between items
InteractionClick StepperTrigger calls onValueChange(step) unless disabled
A11yProcess trigger sets aria-current="step"; nav is an ordered list

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/stepper.json
npx shadcn@latest add https://ui.isaacfei.com/r/stepper.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/stepper.json
bun x shadcn@latest add https://ui.isaacfei.com/r/stepper.json

With 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

PropTypeDefaultDescription
valuenumber0-based current step (controlled with onValueChange).
defaultValuenumber0Initial step when uncontrolled.
onValueChange(value: number) => voidCalled 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.
indicatorsStepperIndicators{}Optional slots: active, completed, inactive, loading. Defaults use check / LoaderCircle when unset.
classNamestringMerged onto the root.
childrenReactNodeTypically a StepperNav tree.

StepperItem

PropTypeDefaultDescription
stepnumber0-based step index (required).
status"wait" | "process" | "finish" | "error"derivedOverride derived status for this item.
loadingbooleanfalseWhen true on the current process item, shows the loading indicator and data-loading. Ignored on non-current / non-process; error wins.
disabledbooleanfalseDisables the trigger; click does not change value.
classNamestringMerged onto the list item.
childrenReactNodeTrigger, separator, and label parts.

Parts

ExportRole
StepperNavOrdered list track (ol)
StepperTriggerClickable control; aria-current="step" when process
StepperIndicatorCircle with number, check when finish, or spinner when loading
StepperSeparatorConnector line between items
StepperTitleStep title
StepperDescriptionOptional muted description

On this page