shadimage

Stepper

A numbered progress indicator for a multi-step flow.

ui/stepper.tsx

"use client";

import { CheckIcon } from "blode-icons-react";
import { cva } from "class-variance-authority";
import { Children, createContext, isValidElement, useContext } from "react";
import type * as React from "react";

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

type StepperOrientation = "horizontal" | "vertical";

// Same vocabulary as `ProgressItemState` in progress-list.tsx, exposed on the same
// `data-state` attribute, so a consumer styles both components with one selector.
type StepState = "completed" | "current" | "pending";

interface StepperContextValue {
  count: number;
  orientation: StepperOrientation;
  value: number;
}

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

// A step carries only its position. Everything else about it — completed or not, last or
// not — follows from the root, so there is no second context object to keep in sync.
const StepIndexContext = createContext<number | null>(null);

const resolveState = (index: number, value: number): StepState => {
  if (index < value) {
    return "completed";
  }
  return index === value ? "current" : "pending";
};

const useStepper = (): StepperContextValue => {
  const context = useContext(StepperContext);
  if (!context) {
    throw new Error("Stepper parts must be used within a <Stepper>");
  }
  return context;
};

const useStep = () => {
  const { count, value } = useStepper();
  const index = useContext(StepIndexContext);

  if (index === null) {
    throw new Error("Step parts must be used within a <Step>");
  }

  return { index, isLast: index === count - 1, state: resolveState(index, value) };
};

const stateDescriptions: Record<StepState, string> = {
  completed: "completed",
  current: "current step",
  pending: "not started",
};

export interface StepperProps extends Omit<React.ComponentProps<"ol">, "children"> {
  /** The steps of the flow — one `<Step>` per step */
  children: React.ReactNode;
  /** Layout direction (default: "horizontal") */
  orientation?: StepperOrientation;
  /** Zero-based index of the active step. Steps before it are completed */
  value: number;
}

const Stepper = ({
  children,
  className,
  orientation = "horizontal",
  value,
  ...props
}: StepperProps) => {
  // oxlint-disable-next-line react/no-react-children -- index assignment only; children are never cloned or mutated
  const items = Children.toArray(children);

  return (
    // oxlint-disable-next-line react/jsx-no-constructed-context-values -- the React Compiler memoises this; a manual useMemo here trips PreserveManualMemo instead
    <StepperContext.Provider value={{ count: items.length, orientation, value }}>
      <ol
        className={cn(
          "flex w-full",
          orientation === "horizontal" ? "items-start gap-4" : "flex-col",
          className,
        )}
        data-orientation={orientation}
        data-slot="stepper"
        {...props}
      >
        {items.map((child, index) => (
          <StepIndexContext.Provider
            key={isValidElement(child) && child.key !== null ? child.key : index}
            value={index}
          >
            {child}
          </StepIndexContext.Provider>
        ))}
      </ol>
    </StepperContext.Provider>
  );
};

export interface StepProps extends React.ComponentProps<"li"> {
  /** Position of this step, when it is not a direct child of `<Stepper>` */
  index?: number;
}

const Step = ({ children, className, index: indexProp, ...props }: StepProps) => {
  const { count, orientation, value } = useStepper();
  const positionalIndex = useContext(StepIndexContext);
  const index = indexProp ?? positionalIndex ?? 0;
  const state = resolveState(index, value);

  return (
    <StepIndexContext.Provider value={index}>
      <li
        aria-current={state === "current" ? "step" : undefined}
        className={cn(
          // A bare <StepLabel> sits next to the 32px indicator, so it carries the 6px
          // offset that optically centres its 20px line on the circle. Inside
          // <StepContent> that offset is the container's padding instead.
          "group/step flex [&>[data-slot=step-label]]:mt-1.5",
          orientation === "horizontal"
            ? "min-w-0 flex-1 items-start gap-2 last:flex-none"
            : "relative gap-3 pb-6 last:pb-0",
          className,
        )}
        data-slot="step"
        data-state={state}
        {...props}
      >
        <span className="sr-only">
          Step {index + 1} of {count}, {stateDescriptions[state]}.
        </span>
        {children}
      </li>
    </StepIndexContext.Provider>
  );
};

const stepIndicatorVariants = cva(
  "flex size-8 shrink-0 items-center justify-center rounded-full border font-medium text-sm tabular-figures transition-[background-color,border-color,box-shadow,color] duration-150 ease-out [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0",
  {
    defaultVariants: {
      state: "pending",
    },
    variants: {
      state: {
        completed: "border-primary bg-primary text-primary-foreground",
        current: "border-primary bg-background text-foreground ring-2 ring-primary/25",
        pending: "border-border bg-background text-muted-foreground",
      },
    },
  },
);

const StepIndicator = ({ children, className, ...props }: React.ComponentProps<"span">) => {
  const { index, state } = useStep();

  let content: React.ReactNode = <CheckIcon aria-hidden="true" />;
  if (children) {
    content = children;
  } else if (state !== "completed") {
    content = index + 1;
  }

  return (
    <span
      aria-hidden="true"
      className={cn(stepIndicatorVariants({ className, state }))}
      data-slot="step-indicator"
      {...props}
    >
      {content}
    </span>
  );
};

const StepContent = ({ className, ...props }: React.ComponentProps<"span">) => (
  <span
    className={cn("flex min-w-0 flex-col gap-0.5 pt-1.5", className)}
    data-slot="step-content"
    {...props}
  />
);

const stepLabelVariants = cva("font-medium text-sm", {
  defaultVariants: {
    state: "pending",
  },
  variants: {
    state: {
      completed: "text-foreground",
      current: "text-foreground",
      pending: "text-muted-foreground",
    },
  },
});

const StepLabel = ({ className, ...props }: React.ComponentProps<"span">) => {
  const { state } = useStep();

  return (
    <span
      className={cn(stepLabelVariants({ className, state }))}
      data-slot="step-label"
      {...props}
    />
  );
};

const StepDescription = ({ className, ...props }: React.ComponentProps<"span">) => (
  <span
    className={cn("text-muted-foreground text-xs", className)}
    data-slot="step-description"
    {...props}
  />
);

const StepSeparator = ({ className, ...props }: React.ComponentProps<"span">) => {
  const { orientation } = useStepper();
  const { isLast, state } = useStep();

  if (isLast) {
    return null;
  }

  return (
    <span
      aria-hidden="true"
      className={cn(
        "bg-border transition-colors duration-150 ease-out",
        orientation === "horizontal"
          ? "mt-4 ml-2 h-px min-w-6 flex-1"
          : "-translate-x-1/2 absolute top-9 bottom-1 left-4 w-px",
        state === "completed" && "bg-primary",
        className,
      )}
      data-orientation={orientation}
      data-slot="step-separator"
      data-state={state}
      {...props}
    />
  );
};

export { Step, StepContent, StepDescription, StepIndicator, StepLabel, StepSeparator, Stepper };
Where it goes
onboarding

DependenciesBundle weight · light

blode-icons-reactclass-variance-authority