# Stepper

> Steps joined by a tube that fills as you progress.

Category: Navigation. Docs: https://neonkit.heyaadi.com/components/stepper

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/stepper.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/stepper`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Icons](https://neonkit.heyaadi.com/components/icons.md). The CLI installs them too.

## Usage

```tsx
import { Step, Stepper } from "@/components/ui/stepper"

<Stepper value={step} onValueChange={setStep}>
  <Step title="Cart" />
  <Step title="Shipping" description="Address and speed" />
  <Step title="Payment" />
</Stepper>
```

## Examples

### Checkout

Each connector fills with light as you advance, the finished step draws its check, and the next one lights up as the light reaches it. Steps you have reached stay clickable.

```tsx
"use client";

import * as React from "react";
import { ArrowLeft, ArrowRight, CircleCheck, CreditCard, RotateCcw, Truck, Zap } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Step, Stepper } from "@/components/ui/stepper";

const steps = [
  { title: "Cart", description: "2 items" },
  { title: "Shipping", description: "Address and speed" },
  { title: "Payment", description: "Card on file" },
  { title: "Review", description: "Confirm and pay" },
];

const row = "flex items-baseline justify-between gap-4 text-sm";

const deliveries = [
  { id: "standard", icon: Truck, title: "Standard delivery", detail: "3–5 business days, by Fri 24", price: "Free" },
  { id: "next-day", icon: Zap, title: "Next day", detail: "Order by 16:00 for Tuesday", price: "$12.00" },
];

function DeliveryOptions() {
  const [speed, setSpeed] = React.useState("standard");
  return (
    <div role="radiogroup" aria-label="Delivery speed" className="grid content-start gap-2">
      {deliveries.map(({ id, icon: Icon, title, detail, price }) => {
        const on = speed === id;
        return (
          <button
            key={id}
            type="button"
            role="radio"
            aria-checked={on}
            onClick={() => setSpeed(id)}
            className={
              on
                ? "flex items-start gap-3 rounded-lg border border-[rgb(var(--neon)/0.35)] bg-[rgb(var(--neon)/0.06)] p-3 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
                : "flex items-start gap-3 rounded-lg border border-white/10 p-3 text-left text-sm outline-none transition-colors hover:border-white/20 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
            }
          >
            <Icon aria-hidden className={on ? "mt-0.5 size-4 shrink-0 text-[rgb(var(--neon))]" : "mt-0.5 size-4 shrink-0 text-white/45"} />
            <span className="grid flex-1 gap-0.5">
              <span className={on ? "text-white/90" : "text-white/75"}>{title}</span>
              <span className="text-xs text-white/50">{detail}</span>
            </span>
            <span className="text-xs tabular-nums text-white/60">{price}</span>
          </button>
        );
      })}
    </div>
  );
}

function Panel({ step }: { step: number }) {
  if (step === 0) {
    return (
      <div className="grid gap-3">
        <div className={row}>
          <span className="grid">
            <span className="text-white/90">Halo desk lamp</span>
            <span className="text-xs text-white/50">Graphite · 2700K</span>
          </span>
          <span className="tabular-nums text-white/80">$129.00</span>
        </div>
        <div className={row}>
          <span className="grid">
            <span className="text-white/90">Spare tube, 40 cm</span>
            <span className="text-xs text-white/50">Qty 2</span>
          </span>
          <span className="tabular-nums text-white/80">$36.00</span>
        </div>
        <div className={`${row} border-t border-white/10 pt-3`}>
          <span className="text-white/55">Subtotal</span>
          <span className="font-medium tabular-nums text-white/90">$165.00</span>
        </div>
      </div>
    );
  }

  if (step === 1) {
    return (
      <div className="grid content-start gap-4 sm:grid-cols-2">
        <div className="grid gap-0.5 text-sm">
          <span className="text-xs text-white/50">Ship to</span>
          <span className="text-white/90">Mira Okafor</span>
          <span className="text-white/60">18 Harbour Street, Leith</span>
          <span className="text-white/60">Edinburgh EH6 6PZ</span>
        </div>
        <DeliveryOptions />
      </div>
    );
  }

  if (step === 2) {
    return (
      <div className="grid gap-3">
        <div className="flex items-center gap-3 rounded-lg border border-white/10 bg-white/[0.03] p-3">
          <CreditCard aria-hidden className="size-4 shrink-0 text-white/60" />
          <span className="grid flex-1 text-sm">
            <span className="text-white/90">Visa ending 4242</span>
            <span className="text-xs text-white/50">Expires 08/28</span>
          </span>
          <span className="text-xs text-white/50">Default</span>
        </div>
        <p className="text-xs text-white/50">Billing address is the same as shipping.</p>
      </div>
    );
  }

  if (step === 3) {
    return (
      <div className="grid gap-2">
        <div className={row}>
          <span className="text-white/55">Items</span>
          <span className="tabular-nums text-white/80">$165.00</span>
        </div>
        <div className={row}>
          <span className="text-white/55">Shipping</span>
          <span className="text-white/80">Free</span>
        </div>
        <div className={row}>
          <span className="text-white/55">VAT (20%)</span>
          <span className="tabular-nums text-white/80">$33.00</span>
        </div>
        <div className={`${row} border-t border-white/10 pt-3`}>
          <span className="text-white/90">Total</span>
          <span className="text-base font-medium tabular-nums text-white">$198.00</span>
        </div>
      </div>
    );
  }

  return (
    <div className="flex flex-col items-center justify-center gap-2 text-center">
      <CircleCheck
        aria-hidden
        className="size-6 text-[rgb(var(--neon))] [filter:drop-shadow(0_0_8px_rgb(var(--neon)/0.7))]"
      />
      <p className="text-sm font-medium text-white/90">Order NK-20418 placed</p>
      <p className="text-xs text-white/50">A receipt is on its way to mira@lumenlabs.dev.</p>
    </div>
  );
}

export default function StepperDemo() {
  const [step, setStep] = React.useState(1);
  const [reached, setReached] = React.useState(1);
  const placed = step === steps.length;

  function go(next: number) {
    setStep(next);
    setReached((furthest) => Math.max(furthest, next));
  }

  function restart() {
    setStep(0);
    setReached(0);
  }

  return (
    <div className="grid w-full max-w-xl gap-6">
      <Stepper value={step} onValueChange={go} clickable aria-label="Checkout">
        {steps.map((item, index) => (
          <Step
            key={item.title}
            title={item.title}
            description={item.description}
            disabled={placed || index > reached}
          />
        ))}
      </Stepper>

      <div className="grid min-h-[156px] content-start rounded-xl border border-white/10 bg-white/[0.03] p-5">
        <Panel step={step} />
      </div>

      <div className="flex items-center justify-between">
        {placed ? (
          <Button variant="ghost" size="sm" onClick={restart}>
            <RotateCcw /> Start over
          </Button>
        ) : (
          <Button variant="ghost" size="sm" onClick={() => go(step - 1)} disabled={step === 0}>
            <ArrowLeft /> Back
          </Button>
        )}
        {!placed && (
          <Button variant="solid" size="sm" onClick={() => go(step + 1)}>
            {step === steps.length - 1 ? "Place order" : "Continue"}
            {step < steps.length - 1 && <ArrowRight />}
          </Button>
        )}
      </div>
    </div>
  );
}
```

### Vertical with an error

status="error" turns a step red with a cross. Retry and watch the tube run down to the deploy.

```tsx
"use client";

import * as React from "react";
import { RotateCw } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Step, Stepper } from "@/components/ui/stepper";

const pipeline = [
  { title: "Clone repository", running: "Fetching main…", done: "main · 8f3c2a1 · 1.2s" },
  { title: "Install dependencies", running: "Resolving packages…", done: "412 packages from cache · 6.8s" },
  {
    title: "Run checks",
    running: "Type-checking and running tests…",
    done: "Types, lint and 184 tests · 21.4s",
    failed: "2 type errors in src/app/page.tsx",
  },
  { title: "Deploy to production", running: "Uploading build output…", done: "Live at aurora.lumenlabs.dev" },
];

export default function StepperVertical() {
  const [value, setValue] = React.useState(2);
  const [failed, setFailed] = React.useState(true);
  const [active, setActive] = React.useState(false);
  const running = active && value < pipeline.length;
  const finished = value === pipeline.length;

  React.useEffect(() => {
    if (!running) return;
    const timer = window.setTimeout(() => setValue((step) => step + 1), 1100);
    return () => window.clearTimeout(timer);
  }, [running, value]);

  function retry() {
    setFailed(false);
    setActive(true);
  }

  function redeploy() {
    setValue(0);
    setActive(true);
  }

  return (
    <div className="grid w-full max-w-sm gap-5 rounded-xl border border-white/10 bg-white/[0.03] p-5">
      <div className="flex items-start justify-between gap-4">
        <div className="grid gap-0.5">
          <h3 className="text-sm font-medium text-white/90">Deployment #1284</h3>
          <p aria-live="polite" className="text-xs text-white/50">
            {finished ? "Ready" : running ? "Building" : "Failed after 29.4s"}
          </p>
        </div>
        <Button
          size="sm"
          variant="outline"
          onClick={finished ? redeploy : retry}
          disabled={running}
          loading={running}
        >
          {!running && <RotateCw />}
          {finished ? "Redeploy" : running ? "Running" : "Retry"}
        </Button>
      </div>

      <Stepper orientation="vertical" value={value} aria-label="Deployment steps">
        {pipeline.map((step, index) => {
          const error = failed && "failed" in step && index === value;
          const description =
            index < value
              ? step.done
              : index > value
                ? "Waiting"
                : error
                  ? step.failed
                  : running
                    ? step.running
                    : "Waiting";
          return (
            <Step
              key={step.title}
              title={step.title}
              description={description}
              status={error ? "error" : undefined}
            />
          );
        })}
      </Stepper>
    </div>
  );
}
```

### Icons and optional steps

An icon replaces the number until the step is done. optional adds a caption under the title.

```tsx
"use client";

import * as React from "react";
import { Building2, GitBranch, UserRound, UsersRound } from "@/components/ui/icons";
import { Step, Stepper } from "@/components/ui/stepper";

const steps = [
  { title: "Profile", icon: <UserRound />, hint: "Your name and avatar, as teammates will see them." },
  { title: "Workspace", icon: <Building2 />, hint: "Pick a name and a URL. You can change both later." },
  { title: "Invite team", icon: <UsersRound />, hint: "Send invites now, or skip and share a link later.", optional: true },
  { title: "Connect Git", icon: <GitBranch />, hint: "Import a repository to deploy on every push." },
];

export default function StepperIcons() {
  const [step, setStep] = React.useState(2);

  return (
    <div className="grid w-full max-w-xl gap-5">
      <Stepper value={step} onValueChange={setStep} clickable aria-label="Workspace setup">
        {steps.map((item) => (
          <Step key={item.title} title={item.title} icon={item.icon} optional={item.optional} />
        ))}
      </Stepper>
      <p aria-live="polite" className="text-center text-sm text-white/55">
        {steps[step].hint}
      </p>
    </div>
  );
}
```

## API reference

### Stepper

Renders an ol and accepts every native attribute. Its children should be Step elements.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | Index of the current step when controlled. Earlier steps are complete; pass the step count to complete them all. |
| `defaultValue` | `number` | `0` | Initial current step when uncontrolled. |
| `onValueChange` | `(index: number) => void` | — | Called when a clickable step is picked. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Horizontal centers labels under the markers and hides descriptions below sm. |
| `clickable` | `boolean` | `false` | Render each step as a button that moves the stepper to it. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### Step

Renders an li.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `ReactNode` | — | Step name. |
| `description` | `ReactNode` | — | Second line under the title. |
| `icon` | `ReactNode` | — | Shown in the marker instead of the number until the step is complete. |
| `status` | `"error"` | — | Marks the step as failed: red marker, cross and title. |
| `optional` | `boolean` | `false` | Shows an "Optional" caption under the title. |
| `disabled` | `boolean` | `false` | With a clickable stepper, blocks jumping to this step. |

## Accessibility

- The steps are an ordered list, and the current one has aria-current="step".
- Completed and failed steps add hidden text (", completed" or ", failed") so the state is not conveyed by color alone.
- With clickable, each step is a native button: Tab moves between them and Enter or Space selects. Disabled steps are skipped.
- Markers and connectors are decorative and hidden from screen readers.
- The connector fill and the check are CSS transitions, so they reverse cleanly when interrupted; with reduced motion they switch instantly.

## Source

`components/ui/stepper.tsx`

```tsx
"use client";

import * as React from "react";
import { X } from "@/components/ui/icons";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type Orientation = "horizontal" | "vertical";

type StepperContextValue = {
  value: number;
  count: number;
  orientation: Orientation;
  clickable: boolean;
  select: (index: number) => void;
};

const StepperContext = React.createContext<StepperContextValue | null>(null);
const StepIndexContext = React.createContext(-1);

function useStepper(component: string) {
  const context = React.useContext(StepperContext);
  if (!context) throw new Error(`<${component}> must be used inside <Stepper>.`);
  return context;
}

export interface StepperProps
  extends Omit<React.OlHTMLAttributes<HTMLOListElement>, "color" | "defaultValue" | "onChange"> {
  /** Index of the current step when controlled. Steps before it are complete; pass the step count to complete them all. */
  value?: number;
  /** Initial current step when uncontrolled. */
  defaultValue?: number;
  onValueChange?: (index: number) => void;
  orientation?: Orientation;
  /** Render each step as a button that moves the stepper to it. */
  clickable?: boolean;
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
}

const Stepper = React.forwardRef<HTMLOListElement, StepperProps>(
  (
    {
      className,
      style,
      color,
      value: valueProp,
      defaultValue = 0,
      onValueChange,
      orientation = "horizontal",
      clickable = false,
      children,
      ...props
    },
    ref,
  ) => {
    const steps = React.Children.toArray(children).filter(React.isValidElement);
    const count = steps.length;
    const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
    const value = Math.min(count, Math.max(0, Math.floor(valueProp ?? uncontrolled) || 0));

    const select = React.useCallback(
      (index: number) => {
        if (index === value) return;
        if (valueProp === undefined) setUncontrolled(index);
        onValueChange?.(index);
      },
      [value, valueProp, onValueChange],
    );

    const context = { value, count, orientation, clickable, select };

    return (
      <StepperContext.Provider value={context}>
        <ol
          ref={ref}
          data-orientation={orientation}
          style={neonVars(color, style)}
          className={cn(
            "[--neon:var(--neon-accent,34_211_238)]",
            orientation === "horizontal" ? "flex w-full items-start" : "flex flex-col",
            className,
          )}
          {...props}
        >
          {steps.map((step, index) => (
            <StepIndexContext.Provider key={step.key ?? index} value={index}>
              {step}
            </StepIndexContext.Provider>
          ))}
        </ol>
      </StepperContext.Provider>
    );
  },
);
Stepper.displayName = "Stepper";

const markers = {
  upcoming:
    "border-white/10 bg-white/[0.03] text-white/40 shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_-1px_0_rgb(0_0_0/0.3)]",
  // The halo arrives a beat late, as the connector's light reaches it.
  current:
    "border-[rgb(var(--neon)/0.85)] bg-[rgb(var(--neon)/0.1)] text-[rgb(var(--neon))] [text-shadow:0_0_8px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1)))] shadow-[0_0_0_4px_rgb(var(--neon)/0.12),0_0_22px_rgb(var(--neon)/CALC(0.45*var(--neon-glow,1))),inset_0_0_8px_rgb(var(--neon)/CALC(0.25*var(--neon-glow,1)))] delay-[220ms]",
  complete:
    "border-[rgb(var(--neon)/0.55)] bg-[rgb(var(--neon)/0.14)] text-[rgb(var(--neon))] shadow-[0_0_10px_rgb(var(--neon)/CALC(0.22*var(--neon-glow,1)))]",
  error:
    "border-[rgb(255_77_94/0.8)] bg-[rgb(255_77_94/0.12)] text-[#ff4d5e] shadow-[0_0_14px_rgb(255_77_94/CALC(0.35*var(--neon-glow,1)))]",
  errorCurrent:
    "border-[#ff4d5e] bg-[rgb(255_77_94/0.12)] text-[#ff4d5e] shadow-[0_0_0_4px_rgb(255_77_94/0.12),0_0_22px_rgb(255_77_94/CALC(0.45*var(--neon-glow,1)))]",
} as const;

const titles = {
  upcoming: "text-white/55",
  current: "text-white",
  complete: "text-white/80",
} as const;

export interface StepProps
  extends Omit<React.LiHTMLAttributes<HTMLLIElement>, "title" | "children"> {
  title: React.ReactNode;
  description?: React.ReactNode;
  /** Replaces the step number in the marker until the step is complete. */
  icon?: React.ReactNode;
  /** Marks the step as failed: a red marker with a cross. */
  status?: "error";
  /** Shows an "Optional" caption under the title. */
  optional?: boolean;
  /** With a clickable stepper, blocks jumping to this step. */
  disabled?: boolean;
}

const Step = React.forwardRef<HTMLLIElement, StepProps>(
  ({ className, title, description, icon, status, optional = false, disabled = false, ...props }, ref) => {
    const index = React.useContext(StepIndexContext);
    const { value, count, orientation, clickable, select } = useStepper("Step");

    const state = index < value ? "complete" : index === value ? "current" : "upcoming";
    const error = status === "error";
    const last = index === count - 1;
    const vertical = orientation === "vertical";
    const current = state === "current";
    const showCheck = state === "complete" && !error;

    const interactive = clickable && !disabled;

    const marker = (
      <span
        aria-hidden
        className={cn(
          "relative grid size-8 shrink-0 place-items-center rounded-full border text-[13px] font-medium leading-none tabular-nums",
          "transition-[border-color,background-color,color,box-shadow,text-shadow,transform,scale] duration-300 ease-out motion-reduce:transition-none",
          "[&_svg]:size-4",
          error ? (current ? markers.errorCurrent : markers.error) : markers[state],
          interactive && "group-active/step:scale-[0.94]",
          // Hovering an unlit step warms its glass a little.
          interactive && state === "upcoming" && !error && "group-hover/step:border-white/25 group-hover/step:text-white/70",
        )}
      >
        <span
          className={cn(
            "grid place-items-center transition-opacity duration-200 [grid-area:1/1]",
            showCheck || error ? "opacity-0" : "opacity-100",
          )}
        >
          {icon ?? index + 1}
        </span>
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth={2.5}
          strokeLinecap="round"
          strokeLinejoin="round"
          className="[grid-area:1/1] [filter:drop-shadow(0_0_4px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1))))]"
        >
          <path
            d="M5 12.5l4.5 4.5L19 7.5"
            pathLength={1}
            strokeDasharray={1}
            strokeDashoffset={showCheck ? 0 : 1}
            className={cn(
              "transition-[stroke-dashoffset] ease-out motion-reduce:transition-none",
              showCheck ? "duration-300 delay-100" : "duration-150",
            )}
          />
        </svg>
        {error && <X className="[grid-area:1/1]" strokeWidth={2.5} />}
      </span>
    );

    const text = (
      <span className={cn("grid min-w-0 gap-0.5", vertical ? "pt-1.5 text-left" : "mt-2.5 justify-items-center px-2 text-center")}>
        <span
          className={cn(
            "text-sm font-medium leading-5 transition-colors duration-300",
            error ? "text-[#ff4d5e]" : titles[state],
          )}
        >
          {title}
        </span>
        {optional && <span className="text-xs leading-4 text-white/40">Optional</span>}
        {description && (
          <span className={cn("text-xs leading-5 text-white/60", !vertical && "hidden sm:block")}>
            {description}
          </span>
        )}
        {(error || state === "complete") && (
          <span className="sr-only">{error ? ", failed" : ", completed"}</span>
        )}
      </span>
    );

    const layout = vertical ? "flex items-start gap-3" : "flex flex-col items-center";

    const body = clickable ? (
      <button
        type="button"
        disabled={disabled}
        aria-current={current ? "step" : undefined}
        onClick={() => select(index)}
        className={cn(
          layout,
          "group/step relative rounded-xl outline-none",
          "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-4 focus-visible:ring-offset-[#05060a]",
          interactive ? "cursor-pointer" : "cursor-not-allowed",
        )}
      >
        {marker}
        {text}
      </button>
    ) : (
      <div className={layout}>
        {marker}
        {text}
      </div>
    );

    return (
      <li
        ref={ref}
        aria-current={!clickable && current ? "step" : undefined}
        data-state={state}
        data-status={status}
        className={cn(
          "relative",
          vertical ? cn("flex", !last && "pb-7") : "flex min-w-0 flex-1 basis-0 justify-center",
          className,
        )}
        {...props}
      >
        {body}
        {!last && (
          <span
            aria-hidden
            className={cn(
              "pointer-events-none absolute rounded-full bg-white/[0.08]",
              vertical
                ? "bottom-1.5 left-[15px] top-[38px] w-0.5"
                : "left-[calc(50%_+_26px)] right-[calc(-50%_+_26px)] top-[15px] h-0.5",
            )}
          >
            <span
              style={{ transform: `${vertical ? "scaleY" : "scaleX"}(${state === "complete" ? 1 : 0})` }}
              className={cn(
                "absolute inset-0 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_8px_rgb(var(--neon)/CALC(0.75*var(--neon-glow,1))),0_0_2px_rgb(var(--neon)/var(--neon-glow,1))]",
                "transition-transform duration-[400ms] ease-in-out motion-reduce:transition-none",
                vertical ? "origin-top" : "origin-left",
              )}
            />
          </span>
        )}
      </li>
    );
  },
);
Step.displayName = "Step";

export { Stepper, Step };
```
