Stepper

Multi-step progress indicator with horizontal and vertical orientation, completed / active / inactive states, clickable steps and a compound API.

Example

Installation

npx shadcn@latest add https://hirael.com/r/stepper.json

API

<Stepper />

+ native element props
PropTypeDefault
value

The active step, 1-based.

number
defaultValuenumber1
onValueChange((step: number) => void)
orientationOrientation"horizontal"

<StepperItem />

+ native element props
PropTypeDefault
step*

This item's position, 1-based.

number
completed

Force the completed state regardless of the active step.

boolean
disabledbooleanfalse

<StepperTrigger />

+ native element props

No props of its own — forwards everything to the underlying element.

<StepperIndicator />

+ native element props

No props of its own — forwards everything to the underlying element.

<StepperSeparator />

+ native element props

No props of its own — forwards everything to the underlying element.

<StepperTitle />

+ native element props

No props of its own — forwards everything to the underlying element.

<StepperDescription />

+ native element props

No props of its own — forwards everything to the underlying element.

Component source

"use client";

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

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

type Orientation = "horizontal" | "vertical";
type StepState = "active" | "completed" | "inactive";

type StepperCtx = {
  value: number;
  setValue: (step: number) => void;
  orientation: Orientation;
};

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

function useStepper() {
  const ctx = React.useContext(StepperContext);
  if (!ctx) {
    throw new Error("Stepper compound parts must be used inside <Stepper>");
  }
  return ctx;
}

type StepperItemCtx = {
  step: number;
  state: StepState;
  disabled: boolean;
};

const StepperItemContext = React.createContext<StepperItemCtx | null>(null);

function useStepperItem() {
  const ctx = React.useContext(StepperItemContext);
  if (!ctx) {
    throw new Error("Stepper item parts must be used inside <StepperItem>");
  }
  return ctx;
}

export type StepperProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange"
> & {
  /** The active step, 1-based. */
  value?: number;
  defaultValue?: number;
  onValueChange?: (step: number) => void;
  orientation?: Orientation;
};

function Stepper({
  value: valueProp,
  defaultValue = 1,
  onValueChange,
  orientation = "horizontal",
  className,
  ...props
}: StepperProps) {
  const [internal, setInternal] = React.useState(defaultValue);
  const value = valueProp ?? internal;

  const setValue = React.useCallback(
    (step: number) => {
      if (valueProp === undefined) setInternal(step);
      onValueChange?.(step);
    },
    [valueProp, onValueChange],
  );

  const ctx = React.useMemo<StepperCtx>(
    () => ({ value, setValue, orientation }),
    [value, setValue, orientation],
  );

  return (
    <StepperContext.Provider value={ctx}>
      <div
        data-slot="stepper"
        data-orientation={orientation}
        className={cn(
          "group/stepper flex",
          orientation === "horizontal" ? "w-full items-center" : "flex-col",
          className,
        )}
        {...props}
      />
    </StepperContext.Provider>
  );
}

export type StepperItemProps = React.ComponentProps<"div"> & {
  /** This item's position, 1-based. */
  step: number;
  /** Force the completed state regardless of the active step. */
  completed?: boolean;
  disabled?: boolean;
};

function StepperItem({
  step,
  completed,
  disabled = false,
  className,
  ...props
}: StepperItemProps) {
  const { value, orientation } = useStepper();

  const state: StepState =
    completed || step < value
      ? "completed"
      : step === value
        ? "active"
        : "inactive";

  const ctx = React.useMemo<StepperItemCtx>(
    () => ({ step, state, disabled }),
    [step, state, disabled],
  );

  return (
    <StepperItemContext.Provider value={ctx}>
      <div
        data-slot="stepper-item"
        data-state={state}
        className={cn(
          "group/step flex",
          orientation === "horizontal"
            ? "items-center not-last:flex-1"
            : "relative items-start gap-3 pb-8 last:pb-0",
          className,
        )}
        {...props}
      />
    </StepperItemContext.Provider>
  );
}

function StepperTrigger({
  className,
  ...props
}: React.ComponentProps<"button">) {
  const { setValue } = useStepper();
  const { step, state, disabled } = useStepperItem();

  return (
    <button
      type="button"
      data-slot="stepper-trigger"
      aria-current={state === "active" ? "step" : undefined}
      disabled={disabled}
      onClick={() => setValue(step)}
      className={cn(
        "flex items-center gap-3 rounded-md text-start 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 disabled:opacity-50",
        className,
      )}
      {...props}
    />
  );
}

function StepperIndicator({
  className,
  children,
  ...props
}: React.ComponentProps<"span">) {
  const { step, state } = useStepperItem();

  return (
    <span
      data-slot="stepper-indicator"
      data-state={state}
      aria-hidden
      className={cn(
        "inline-flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-medium tabular-nums transition-colors",
        state === "completed" &&
          "border-primary bg-primary text-primary-foreground",
        state === "active" && "border-primary bg-primary/10 text-foreground",
        state === "inactive" && "border-border bg-card text-muted-foreground",
        className,
      )}
      {...props}
    >
      {children ??
        (state === "completed" ? (
          <Check className="size-4" strokeWidth={2.5} />
        ) : (
          step
        ))}
    </span>
  );
}

function StepperSeparator({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="stepper-separator"
      aria-hidden
      className={cn(
        "bg-border transition-colors group-data-[state=completed]/step:bg-primary",
        "group-data-[orientation=horizontal]/stepper:mx-2 group-data-[orientation=horizontal]/stepper:h-0.5 group-data-[orientation=horizontal]/stepper:flex-1",
        "group-data-[orientation=vertical]/stepper:absolute group-data-[orientation=vertical]/stepper:bottom-1 group-data-[orientation=vertical]/stepper:start-4 group-data-[orientation=vertical]/stepper:top-9 group-data-[orientation=vertical]/stepper:w-0.5 group-data-[orientation=vertical]/stepper:-translate-x-1/2 rtl:group-data-[orientation=vertical]/stepper:translate-x-1/2",
        className,
      )}
      {...props}
    />
  );
}

function StepperTitle({ className, ...props }: React.ComponentProps<"span">) {
  const { state } = useStepperItem();
  return (
    <span
      data-slot="stepper-title"
      className={cn(
        "block text-sm font-medium leading-tight transition-colors",
        state === "inactive" ? "text-muted-foreground" : "text-foreground",
        className,
      )}
      {...props}
    />
  );
}

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

export {
  Stepper,
  StepperItem,
  StepperTrigger,
  StepperIndicator,
  StepperSeparator,
  StepperTitle,
  StepperDescription,
};

Dependencies

npm

lucide-react