deepmoon

Stepper

Compound API: Stepper, StepperItem, StepperIndicator, StepperContent.

Horizontal, numbered

  1. Completed step.

    Account

    Created

  2. Completed step.

    Profile

    Completed

  3. Current step.

    Verification

    In progress

  4. Done

    Not yet

Vertical

  1. Completed step.

    Order placed

    We received your order Monday.

  2. Current step.

    Preparing shipment

    Your order is being packed.

  3. Out for delivery

    Estimated Thursday.

With icons

  1. Completed step.

    Cart

  2. Completed step.

    Shipping

  3. Current step.

    Payment

  4. Delivered

Clickable navigation

Try Tab + Enter/Space: every step is a real button. Current: Shipping.

Installation

$ npx shadcn@latest add https://deepmoon.dev/r/stepper.json

npm dependencies: lucide-react

Props

StepperProps
PropTypeRequired
orientationStepperOrientationNo
StepperItemProps
PropTypeRequired
onClick() => voidNo
disabledbooleanNo
StepperIndicatorProps
PropTypeRequired
statusStepperStepStatusNo
iconReact.ReactNodeNo
StepperContentProps
PropTypeRequired
titleReact.ReactNodeYes
descriptionReact.ReactNodeNo
headingLevel"h2" | "h3" | "h4" | "h5" | "h6"No

Code

stepper.tsx
"use client";

import * as React from "react";
import { Check } from "lucide-react";

import { cn } from "@/lib/utils";

export type StepperOrientation = "horizontal" | "vertical";
export type StepperStepStatus = "upcoming" | "current" | "complete";

interface StepperContextValue {
  orientation: StepperOrientation;
  total: number;
}

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

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

const StepperItemIndexContext = React.createContext(0);

export interface StepperProps extends React.ComponentPropsWithoutRef<"ol"> {
  orientation?: StepperOrientation;
}

/**
 * Root list. Same index-via-context pattern as Timeline: derived purely
 * from render order, no refs/effects, safe under StrictMode and identical
 * on server vs. first client render.
 */
export function Stepper({ orientation = "horizontal", className, children, ...props }: StepperProps) {
  const items = React.Children.toArray(children);

  return (
    <StepperContext.Provider value={{ orientation, total: items.length }}>
      <ol
        role="list"
        aria-label="Progress"
        data-orientation={orientation}
        className={cn(
          "relative flex list-none",
          orientation === "horizontal" ? "w-full flex-row" : "flex-col",
          className,
        )}
        {...props}
      >
        {items.map((child, index) => (
          <StepperItemIndexContext.Provider key={index} value={index}>
            {child}
          </StepperItemIndexContext.Provider>
        ))}
      </ol>
    </StepperContext.Provider>
  );
}

export interface StepperItemProps extends Omit<React.ComponentPropsWithoutRef<"li">, "onClick"> {
  /** Present → step renders as a real <button>, keyboard-operable by default. Absent → non-interactive. */
  onClick?: () => void;
  disabled?: boolean;
}

/**
 * A single step. Interactivity lives on this component (not on
 * StepperIndicator) so the whole step — marker and label — is one click
 * target and one native, natively-tabbable control, never a div with a
 * click handler.
 */
export function StepperItem({ onClick, disabled, className, children, ...props }: StepperItemProps) {
  const { orientation, total } = useStepperContext("StepperItem");
  const index = React.useContext(StepperItemIndexContext);
  const isLast = index === total - 1;

  const childArray = React.Children.toArray(children);
  const indicator = childArray.find(
    (child): child is React.ReactElement<StepperIndicatorProps> =>
      React.isValidElement(child) && child.type === StepperIndicator,
  );
  const content = childArray.filter((child) => child !== indicator);
  const status: StepperStepStatus = indicator?.props.status ?? "upcoming";

  const connectorClass = status === "complete" ? "bg-primary" : "bg-border";
  const interactive = Boolean(onClick);

  const body =
    orientation === "horizontal" ? (
      <>
        <div className="relative z-10 flex items-center">
          <span className="shrink-0">{indicator}</span>
          {!isLast && (
            <span
              aria-hidden="true"
              className={cn("h-[var(--stepper-connector-thickness)] flex-1", connectorClass)}
            />
          )}
        </div>
        <div className="mt-[var(--stepper-gap)] flex flex-col gap-0.5">{content}</div>
      </>
    ) : (
      <>
        {!isLast && (
          <span
            aria-hidden="true"
            className={cn(
              "absolute top-[var(--stepper-indicator-size)] bottom-0 left-[calc(var(--stepper-indicator-size)/2)] w-[var(--stepper-connector-thickness)] -translate-x-1/2",
              connectorClass,
            )}
          />
        )}
        <span className="relative z-10 shrink-0">{indicator}</span>
        <div className="flex flex-col gap-0.5 pb-[var(--stepper-gap)]">{content}</div>
      </>
    );

  const wrapperClassName = cn(
    "text-left",
    orientation === "horizontal" ? "flex flex-1 flex-col" : "flex gap-x-[var(--timeline-gutter)]",
  );

  return (
    <li
      className={cn(
        "relative",
        orientation === "horizontal" ? "flex flex-1 flex-col" : "flex",
        className,
      )}
      {...props}
    >
      {interactive ? (
        <button
          type="button"
          onClick={onClick}
          disabled={disabled}
          aria-current={status === "current" ? "step" : undefined}
          className={cn(
            wrapperClassName,
            "cursor-pointer rounded-md outline-none transition-opacity",
            "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            disabled && "cursor-not-allowed opacity-50",
          )}
        >
          {body}
        </button>
      ) : (
        <div aria-current={status === "current" ? "step" : undefined} className={wrapperClassName}>
          {body}
        </div>
      )}
    </li>
  );
}

export interface StepperIndicatorProps extends React.ComponentPropsWithoutRef<"span"> {
  status?: StepperStepStatus;
  icon?: React.ReactNode;
}

const stepStatusLabel: Record<StepperStepStatus, string | null> = {
  upcoming: null,
  current: "Current step. ",
  complete: "Completed step. ",
};

/**
 * The numbered/icon marker. Decorative (aria-hidden) — status is announced
 * via a sibling sr-only span, same reasoning as Timeline's node: text
 * nested inside an aria-hidden element never reaches assistive tech.
 * Falls back to its own 1-based step number when no icon is given, and to
 * a check mark when complete — matches Timeline's shape-over-color-alone
 * approach (empty ring / ring+number / filled+check).
 */
export function StepperIndicator({ status = "upcoming", icon, className, children, ...props }: StepperIndicatorProps) {
  const index = React.useContext(StepperItemIndexContext);
  const label = stepStatusLabel[status];

  return (
    <>
      {label ? <span className="sr-only">{label}</span> : null}
      <span
        aria-hidden="true"
        className={cn(
          "relative flex size-[var(--stepper-indicator-size)] shrink-0 items-center justify-center rounded-full border-[length:var(--stepper-indicator-border-width)] bg-background text-sm font-medium text-muted-foreground",
          status === "upcoming" && "border-border",
          status === "current" && "border-primary text-primary",
          status === "complete" && "border-primary bg-primary text-primary-foreground",
          className,
        )}
        {...props}
      >
        {status === "current" && (
          <span className="absolute inset-0 -z-10 rounded-full bg-primary/40 motion-safe:animate-ping" />
        )}
        {icon ??
          children ??
          (status === "complete" ? (
            <Check className="size-[calc(var(--stepper-indicator-size)*0.5)]" />
          ) : (
            index + 1
          ))}
      </span>
    </>
  );
}

export interface StepperContentProps extends Omit<React.ComponentPropsWithoutRef<"div">, "title"> {
  title: React.ReactNode;
  description?: React.ReactNode;
  /** Heading level for `title` — pick what fits the page's own hierarchy. */
  headingLevel?: "h2" | "h3" | "h4" | "h5" | "h6";
}

export function StepperContent({
  title,
  description,
  headingLevel: Heading = "h3",
  className,
  children,
  ...props
}: StepperContentProps) {
  return (
    <div className={cn("flex flex-col gap-0.5", className)} {...props}>
      <Heading className="text-sm font-semibold leading-snug text-foreground">{title}</Heading>
      {description ? (
        <p className="text-xs leading-relaxed text-muted-foreground">{description}</p>
      ) : null}
      {children}
    </div>
  );
}