# Stepper

The steps of a multi-step flow with complete, current and upcoming states, horizontal or vertical.

Docs: https://corsairui.vercel.app/docs/stepper

## Install

```bash
pnpm dlx shadcn@latest add @corsair-ui/stepper
```

Also adds: `utils`.
npm: `clsx`, `lucide-react`, `tailwind-merge`.

## API

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value / defaultValue / onValueChange` | `number / number / (value) => void` | `defaultValue 0` | The current step, 0-based; steps before it are complete. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Steps in a row or a column. |
| `linear` | `boolean` | `true` | StepperTrigger stays disabled on steps not reached yet. |
| `label / labels` | `string / Partial<StepperLabels>` | `"Progress" / "Completed", "Current", "Upcoming"` | The nav's name, and the state read after each step number. |
| `step` | `number` |  | StepperItem: this step's 0-based index. |

## Styling

Restyle it in the project that installs it: `className` on each part, the theme variables, and these attributes with arbitrary variants (`[&_[data-slot=…]]:…`, `data-[state=…]:…`).

- Every stepper-item, stepper-indicator and stepper-trigger has data-state: "complete", "current" or "upcoming".
- Inside an item, group-data-[state=complete]/stepper-item: styles any child; the separator uses it to colour the line.

data-slot: `stepper`, `stepper-description`, `stepper-indicator`, `stepper-item`, `stepper-list`, `stepper-separator`, `stepper-title`, `stepper-trigger`

State attributes:

- `data-orientation` (computed)
- `data-state` (computed)

## Accessibility

- A nav landmark around an ordered list, so the number of steps is announced.
- The current step has aria-current="step"; each indicator reads its state after the number, since the check and colours are not read.
- StepperTrigger is a native button: Tab reaches it, Enter or Space moves to the step.
- Colour changes are off with prefers-reduced-motion.

## Examples

On the docs page: Checkout, Vertical, with descriptions. The usage examples in the source comments below are the reference.

## Source

What `shadcn add` writes into the project:

### stepper.tsx

```tsx
"use client";

import { CheckIcon } from "lucide-react";
import { createContext, useContext, useState, type ComponentProps } from "react";
import { cn } from "@/registry/default/lib/utils";

type StepperOrientation = "horizontal" | "vertical";
type StepperState = "complete" | "current" | "upcoming";

interface StepperLabels {
  /** Read after the number of a finished step. */
  complete: string;
  /** Read after the number of the step in progress. */
  current: string;
  /** Read after the number of a step not reached yet. An empty string reads nothing. */
  upcoming: string;
}

const defaultLabels: StepperLabels = {
  complete: "Completed",
  current: "Current",
  upcoming: "Upcoming",
};

interface StepperContextValue {
  value: number;
  setValue: (value: number) => void;
  orientation: StepperOrientation;
  linear: boolean;
  labels: StepperLabels;
}

const StepperContext = createContext<StepperContextValue | null>(null);

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

interface StepperItemContextValue {
  step: number;
  state: StepperState;
}

const StepperItemContext = createContext<StepperItemContextValue | null>(null);

function useStepperItem(part: string) {
  const context = useContext(StepperItemContext);
  if (!context) throw new Error(`${part} must be used inside <StepperItem>.`);
  return context;
}

/** True inside a `StepperTrigger`, which then carries `aria-current` instead of the indicator. */
const StepperTriggerContext = createContext(false);

interface StepperProps extends Omit<ComponentProps<"nav">, "defaultValue"> {
  /** The current step, as a 0-based index. Steps before it are complete. */
  value?: number;
  /** The first current step when uncontrolled. */
  defaultValue?: number;
  /** Called with the index of the step a `StepperTrigger` asks for. */
  onValueChange?: (value: number) => void;
  orientation?: StepperOrientation;
  /**
   * When true (the default), a `StepperTrigger` on a step not reached yet is
   * disabled, so steps are taken in order. Set it to false to let people jump
   * to any step.
   */
  linear?: boolean;
  /** Accessible name of the `<nav>` landmark. */
  label?: string;
  /** State text read by screen readers after each step's number. */
  labels?: Partial<StepperLabels>;
}

/**
 * The steps of a multi-step flow (checkout, onboarding, a long form) and
 * where the user is in it. It is a `<nav>` landmark named by `label`
 * ("Progress") around an ordered list, so screen readers announce the number
 * of steps. The current step is marked with `aria-current="step"`, and each
 * indicator reads its state ("Completed", "Current", from `labels`) after the
 * number, since the check mark and colours are not read. Put a
 * `StepperTrigger` in a step to make it a button that moves to it: Tab
 * reaches it and Enter or Space activates it; with `linear` (the default)
 * steps not reached yet stay disabled. Controlled with `value` /
 * `onValueChange` or uncontrolled with `defaultValue`. Colour changes are a
 * transition, turned off with reduced motion.
 *
 * @example
 * <Stepper value={step} onValueChange={setStep}>
 *   <StepperItem step={0}>
 *     <StepperTrigger>
 *       <StepperIndicator />
 *       <StepperTitle>Crew</StepperTitle>
 *     </StepperTrigger>
 *     <StepperSeparator />
 *   </StepperItem>
 *   <StepperItem step={1}>
 *     <StepperTrigger>
 *       <StepperIndicator />
 *       <StepperTitle>Route</StepperTitle>
 *     </StepperTrigger>
 *   </StepperItem>
 * </Stepper>
 */
function Stepper({
  value: valueProp,
  defaultValue = 0,
  onValueChange,
  orientation = "horizontal",
  linear = true,
  label = "Progress",
  labels: labelsProp,
  className,
  children,
  ...props
}: StepperProps) {
  const [internal, setInternal] = useState(defaultValue);
  const value = valueProp ?? internal;
  const setValue = (next: number) => {
    if (valueProp === undefined) setInternal(next);
    onValueChange?.(next);
  };
  const labels = { ...defaultLabels, ...labelsProp };
  return (
    <nav
      data-slot="stepper"
      data-orientation={orientation}
      aria-label={label}
      className={className}
      {...props}
    >
      <ol
        data-slot="stepper-list"
        data-orientation={orientation}
        className={cn(
          "flex",
          orientation === "vertical" ? "flex-col" : "items-center gap-1.5 sm:gap-2"
        )}
      >
        <StepperContext.Provider value={{ value, setValue, orientation, linear, labels }}>
          {children}
        </StepperContext.Provider>
      </ol>
    </nav>
  );
}

interface StepperItemProps extends ComponentProps<"li"> {
  /** This step's 0-based index. */
  step: number;
}

/** One step. Its state is `data-state`: "complete", "current" or "upcoming". */
function StepperItem({ step, className, ...props }: StepperItemProps) {
  const { value, orientation } = useStepper("StepperItem");
  const state: StepperState = step < value ? "complete" : step === value ? "current" : "upcoming";
  return (
    <StepperItemContext.Provider value={{ step, state }}>
      <li
        data-slot="stepper-item"
        data-state={state}
        data-orientation={orientation}
        className={cn(
          "group/stepper-item",
          orientation === "vertical"
            ? "relative flex flex-col pb-6 last:pb-0"
            : // On small screens steps start from their content's width, as only
              // the current one shows its title, and the lines share what is left.
              "flex items-center gap-1.5 sm:gap-2 [&:not(:last-child)]:flex-1 max-sm:[&:not(:last-child)]:flex-auto",
          // The step that gives way when even that does not fit: its title is cut short.
          orientation === "horizontal" && state === "current" && "min-w-0",
          className
        )}
        {...props}
      />
    </StepperItemContext.Provider>
  );
}

/**
 * Makes the step a button that moves to it. Disabled for steps not reached
 * yet while the Stepper is `linear`; pass `disabled` to lock any step.
 */
function StepperTrigger({
  className,
  disabled,
  onClick,
  type = "button",
  ...props
}: ComponentProps<"button">) {
  const { setValue, linear } = useStepper("StepperTrigger");
  const { step, state } = useStepperItem("StepperTrigger");
  return (
    <StepperTriggerContext.Provider value={true}>
      <button
        data-slot="stepper-trigger"
        data-state={state}
        type={type}
        aria-current={state === "current" ? "step" : undefined}
        disabled={disabled ?? (linear && state === "upcoming")}
        onClick={(event) => {
          onClick?.(event);
          if (!event.defaultPrevented) setValue(step);
        }}
        className={cn(
          "inline-flex min-w-0 cursor-pointer items-center gap-2 rounded-md text-left",
          "transition-[color,box-shadow] outline-none motion-reduce:transition-none",
          "focus-visible:ring-ring/50 focus-visible:ring-[3px]",
          "disabled:cursor-not-allowed disabled:opacity-50",
          className
        )}
        {...props}
      />
    </StepperTriggerContext.Provider>
  );
}

/**
 * The round marker with the step's number, or a check mark once complete.
 * Pass children to show something else (an icon) instead of the number.
 */
function StepperIndicator({ className, children, ...props }: ComponentProps<"span">) {
  const { labels } = useStepper("StepperIndicator");
  const { step, state } = useStepperItem("StepperIndicator");
  const inTrigger = useContext(StepperTriggerContext);
  const stateLabel = labels[state];
  return (
    <span
      data-slot="stepper-indicator"
      data-state={state}
      aria-current={!inTrigger && state === "current" ? "step" : undefined}
      className={cn(
        "flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-medium",
        "transition-[color,background-color,border-color] motion-reduce:transition-none",
        "data-[state=upcoming]:bg-muted data-[state=upcoming]:text-muted-foreground",
        "data-[state=current]:border-primary data-[state=current]:bg-background data-[state=current]:text-primary",
        "data-[state=complete]:border-primary data-[state=complete]:bg-primary data-[state=complete]:text-primary-foreground",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {state === "complete" ? (
        <>
          <CheckIcon aria-hidden="true" />
          <span className="sr-only">{step + 1}</span>
        </>
      ) : (
        (children ?? step + 1)
      )}
      {stateLabel ? <span className="sr-only">{`, ${stateLabel}`}</span> : null}
    </span>
  );
}

/**
 * The step's name. In a horizontal Stepper on small screens only the current
 * step shows its title, cut short if it still does not fit, so the row fits
 * a phone; the others are still read out, and still name their `StepperTrigger`.
 */
function StepperTitle({ className, ...props }: ComponentProps<"span">) {
  const stepper = useContext(StepperContext);
  const item = useContext(StepperItemContext);
  const horizontal = stepper?.orientation === "horizontal" && item !== null;
  return (
    <span
      data-slot="stepper-title"
      className={cn(
        "group-data-[state=upcoming]/stepper-item:text-muted-foreground block text-sm font-medium",
        horizontal && (item.state === "current" ? "max-sm:truncate" : "max-sm:sr-only"),
        className
      )}
      {...props}
    />
  );
}

function StepperDescription({ className, ...props }: ComponentProps<"span">) {
  return (
    <span
      data-slot="stepper-description"
      className={cn("text-muted-foreground block text-xs", className)}
      {...props}
    />
  );
}

/**
 * The line to the next step, coloured once this step is complete. Decorative:
 * hidden from screen readers, and not drawn on the last step.
 */
function StepperSeparator({ className, ...props }: ComponentProps<"span">) {
  const { orientation } = useStepper("StepperSeparator");
  return (
    <span
      data-slot="stepper-separator"
      data-orientation={orientation}
      aria-hidden="true"
      className={cn(
        "bg-border block shrink-0 group-last/stepper-item:hidden",
        "group-data-[state=complete]/stepper-item:bg-primary transition-colors motion-reduce:transition-none",
        orientation === "vertical"
          ? "absolute top-10 bottom-2 left-[calc(1rem-0.5px)] w-px"
          : "h-px min-w-2 flex-1 sm:min-w-4",
        className
      )}
      {...props}
    />
  );
}

export {
  Stepper,
  StepperDescription,
  StepperIndicator,
  StepperItem,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
  type StepperItemProps,
  type StepperLabels,
  type StepperOrientation,
  type StepperProps,
  type StepperState,
};
```
