Installation

How to install and set up shadcn-stepper in your project

Install the component using shadcn cli or manually copying the following code in your project

npx shadcn add https://stepperize.vercel.app/r/stepper.json

Or install the component manually copying the following code in your project

Install dependencies

npm install @stepperize/react

Copy and paste the following code in your project

import { Slot } from "@radix-ui/react-slot";
import * as Stepperize from "@stepperize/react";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
 
import { cn } from "@/lib/utils";
import { Button } from "@/registry/new-york/ui/button";
 
const StepperContext = React.createContext<Stepper.ConfigProps | null>(null);
 
const useStepperProvider = (): Stepper.ConfigProps => {
  const context = React.useContext(StepperContext);
  if (!context) {
    throw new Error("useStepper must be used within a StepperProvider.");
  }
  return context;
};
 
const defineStepper = <const Steps extends Stepperize.Step[]>(
  ...steps: Steps
): Stepper.DefineProps<Steps> => {
  const { Scoped, useStepper, ...rest } = Stepperize.defineStepper(...steps);
 
  const StepperContainer = ({
    children,
    className,
    ...props
  }: Omit<React.ComponentProps<"div">, "children"> & {
    children:
      | React.ReactNode
      | ((props: { methods: Stepperize.Stepper<Steps> }) => React.ReactNode);
  }) => {
    const methods = useStepper();
 
    return (
      <div
        date-component="stepper"
        className={cn("w-full", className)}
        {...props}
      >
        {typeof children === "function" ? children({ methods }) : children}
      </div>
    );
  };
 
  return {
    ...rest,
    useStepper,
    Stepper: {
      Provider: ({
        variant = "horizontal",
        labelOrientation = "horizontal",
        tracking = false,
        children,
        className,
        ...props
      }) => {
        return (
          <StepperContext.Provider
            value={{ variant, labelOrientation, tracking }}
          >
            <Scoped
              initialStep={props.initialStep}
              initialMetadata={props.initialMetadata}
            >
              <StepperContainer className={className} {...props}>
                {children}
              </StepperContainer>
            </Scoped>
          </StepperContext.Provider>
        );
      },
      Navigation: ({
        children,
        "aria-label": ariaLabel = "Stepper Navigation",
        ...props
      }) => {
        const { variant } = useStepperProvider();
        return (
          <nav
            date-component="stepper-navigation"
            aria-label={ariaLabel}
            role="tablist"
            {...props}
          >
            <ol
              date-component="stepper-navigation-list"
              className={classForNavigationList({ variant: variant })}
            >
              {children}
            </ol>
          </nav>
        );
      },
      Step: ({ children, className, icon, ...props }) => {
        const { variant, labelOrientation } = useStepperProvider();
        const { current } = useStepper();
 
        const utils = rest.utils;
        const steps = rest.steps;
 
        const stepIndex = utils.getIndex(props.of);
        const step = steps[stepIndex];
        const currentIndex = utils.getIndex(current.id);
 
        const isLast = utils.getLast().id === props.of;
        const isActive = current.id === props.of;
 
        const dataState = getStepState(currentIndex, stepIndex);
        const childMap = useStepChildren(children);
 
        const title = childMap.get("title");
        const description = childMap.get("description");
        const panel = childMap.get("panel");
 
        if (variant === "circle") {
          return (
            <li
              date-component="stepper-step"
              className={cn(
                "flex shrink-0 items-center gap-4 rounded-md transition-colors",
                className
              )}
            >
              <CircleStepIndicator
                currentStep={stepIndex + 1}
                totalSteps={steps.length}
              />
              <div
                date-component="stepper-step-content"
                className="flex flex-col items-start gap-1"
              >
                {title}
                {description}
              </div>
            </li>
          );
        }
 
        return (
          <>
            <li
              date-component="stepper-step"
              className={cn([
                "group peer relative flex items-center gap-2",
                "data-[variant=vertical]:flex-row",
                "data-[label-orientation=vertical]:w-full",
                "data-[label-orientation=vertical]:flex-col",
                "data-[label-orientation=vertical]:justify-center",
              ])}
              data-variant={variant}
              data-label-orientation={labelOrientation}
              data-state={dataState}
              data-disabled={props.disabled}
            >
              <Button
                id={`step-${step.id}`}
                date-component="stepper-step-indicator"
                type="button"
                role="tab"
                tabIndex={dataState !== "inactive" ? 0 : -1}
                className="rounded-full"
                variant={dataState !== "inactive" ? "default" : "secondary"}
                size="icon"
                aria-controls={`step-panel-${props.of}`}
                aria-current={isActive ? "step" : undefined}
                aria-posinset={stepIndex + 1}
                aria-setsize={steps.length}
                aria-selected={isActive}
                onKeyDown={(e) =>
                  onStepKeyDown(
                    e,
                    utils.getNext(props.of),
                    utils.getPrev(props.of)
                  )
                }
                {...props}
              >
                {icon ?? stepIndex + 1}
              </Button>
              {variant === "horizontal" && labelOrientation === "vertical" && (
                <StepperSeparator
                  orientation="horizontal"
                  labelOrientation={labelOrientation}
                  isLast={isLast}
                  state={dataState}
                  disabled={props.disabled}
                />
              )}
              <div
                date-component="stepper-step-content"
                className="flex flex-col items-start"
              >
                {title}
                {description}
              </div>
            </li>
 
            {variant === "horizontal" && labelOrientation === "horizontal" && (
              <StepperSeparator
                orientation="horizontal"
                isLast={isLast}
                state={dataState}
                disabled={props.disabled}
              />
            )}
 
            {variant === "vertical" && (
              <div className="flex gap-4">
                {!isLast && (
                  <div className="flex justify-center ps-[calc(var(--spacing)_*_4.5_-_1px)]">
                    <StepperSeparator
                      orientation="vertical"
                      isLast={isLast}
                      state={dataState}
                      disabled={props.disabled}
                    />
                  </div>
                )}
                <div className="my-3 flex-1 ps-4">{panel}</div>
              </div>
            )}
          </>
        );
      },
      Title,
      Description,
      Panel: ({ children, asChild, ...props }) => {
        const Comp = asChild ? Slot : "div";
        const { tracking } = useStepperProvider();
 
        return (
          <Comp
            date-component="stepper-step-panel"
            ref={(node) => scrollIntoStepperPanel(node, tracking)}
            {...props}
          >
            {children}
          </Comp>
        );
      },
      Controls: ({ children, className, asChild, ...props }) => {
        const Comp = asChild ? Slot : "div";
        return (
          <Comp
            date-component="stepper-controls"
            className={cn("flex justify-end gap-4", className)}
            {...props}
          >
            {children}
          </Comp>
        );
      },
    },
  };
};
 
const Title = ({
  children,
  className,
  asChild,
  ...props
}: React.ComponentProps<"h4"> & { asChild?: boolean }) => {
  const Comp = asChild ? Slot : "h4";
 
  return (
    <Comp
      date-component="stepper-step-title"
      className={cn("text-base font-medium", className)}
      {...props}
    >
      {children}
    </Comp>
  );
};
 
const Description = ({
  children,
  className,
  asChild,
  ...props
}: React.ComponentProps<"p"> & { asChild?: boolean }) => {
  const Comp = asChild ? Slot : "p";
 
  return (
    <Comp
      date-component="stepper-step-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    >
      {children}
    </Comp>
  );
};
 
const StepperSeparator = ({
  orientation,
  isLast,
  labelOrientation,
  state,
  disabled,
}: {
  isLast: boolean;
  state: string;
  disabled?: boolean;
} & VariantProps<typeof classForSeparator>) => {
  if (isLast) {
    return null;
  }
  return (
    <div
      date-component="stepper-separator"
      data-orientation={orientation}
      data-state={state}
      data-disabled={disabled}
      role="separator"
      tabIndex={-1}
      className={classForSeparator({ orientation, labelOrientation })}
    />
  );
};
 
const CircleStepIndicator = ({
  currentStep,
  totalSteps,
  size = 80,
  strokeWidth = 6,
}: Stepper.CircleStepIndicatorProps) => {
  const radius = (size - strokeWidth) / 2;
  const circumference = radius * 2 * Math.PI;
  const fillPercentage = (currentStep / totalSteps) * 100;
  const dashOffset = circumference - (circumference * fillPercentage) / 100;
  return (
    <div
      date-component="stepper-step-indicator"
      role="progressbar"
      aria-valuenow={currentStep}
      aria-valuemin={1}
      aria-valuemax={totalSteps}
      tabIndex={-1}
      className="relative inline-flex items-center justify-center"
    >
      <svg width={size} height={size}>
        <title>Step Indicator</title>
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke="currentColor"
          strokeWidth={strokeWidth}
          className="text-muted-foreground"
        />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke="currentColor"
          strokeWidth={strokeWidth}
          strokeDasharray={circumference}
          strokeDashoffset={dashOffset}
          className="text-primary transition-all duration-300 ease-in-out"
          transform={`rotate(-90 ${size / 2} ${size / 2})`}
        />
      </svg>
      <div className="absolute inset-0 flex items-center justify-center">
        <span className="text-sm font-medium" aria-live="polite">
          {currentStep} of {totalSteps}
        </span>
      </div>
    </div>
  );
};
 
const classForNavigationList = cva("flex gap-2", {
  variants: {
    variant: {
      horizontal: "flex-row items-center justify-between",
      vertical: "flex-col",
      circle: "flex-row items-center justify-between",
    },
  },
});
 
const classForSeparator = cva(
  [
    "bg-muted",
    "data-[state=completed]:bg-primary data-[disabled]:opacity-50",
    "transition-all duration-300 ease-in-out",
  ],
  {
    variants: {
      orientation: {
        horizontal: "h-0.5 flex-1",
        vertical: "h-full w-0.5",
      },
      labelOrientation: {
        vertical:
          "absolute left-[calc(50%+30px)] right-[calc(-50%+20px)] top-5 block shrink-0",
      },
    },
  }
);
 
function scrollIntoStepperPanel(
  node: HTMLDivElement | null,
  tracking?: boolean
) {
  if (tracking) {
    node?.scrollIntoView({ behavior: "smooth", block: "center" });
  }
}
 
const useStepChildren = (children: React.ReactNode) => {
  return React.useMemo(() => extractChildren(children), [children]);
};
 
const extractChildren = (children: React.ReactNode) => {
  const childrenArray = React.Children.toArray(children);
  const map = new Map<string, React.ReactNode>();
 
  for (const child of childrenArray) {
    if (React.isValidElement(child)) {
      if (child.type === Title) {
        map.set("title", child);
      } else if (child.type === Description) {
        map.set("description", child);
      } else {
        map.set("panel", child);
      }
    }
  }
 
  return map;
};
 
const onStepKeyDown = (
  e: React.KeyboardEvent<HTMLButtonElement>,
  nextStep: Stepperize.Step,
  prevStep: Stepperize.Step
) => {
  const { key } = e;
  const directions = {
    next: ["ArrowRight", "ArrowDown"],
    prev: ["ArrowLeft", "ArrowUp"],
  };
 
  if (directions.next.includes(key) || directions.prev.includes(key)) {
    const direction = directions.next.includes(key) ? "next" : "prev";
    const step = direction === "next" ? nextStep : prevStep;
 
    if (!step) {
      return;
    }
 
    const stepElement = document.getElementById(`step-${step.id}`);
    if (!stepElement) {
      return;
    }
 
    const isActive =
      stepElement.parentElement?.getAttribute("data-state") !== "inactive";
    if (isActive || direction === "prev") {
      stepElement.focus();
    }
  }
};
 
const getStepState = (currentIndex: number, stepIndex: number) => {
  if (currentIndex === stepIndex) {
    return "active";
  }
  if (currentIndex > stepIndex) {
    return "completed";
  }
  return "inactive";
};
 
namespace Stepper {
  export type StepperVariant = "horizontal" | "vertical" | "circle";
  export type StepperLabelOrientation = "horizontal" | "vertical";
 
  export type ConfigProps = {
    variant?: StepperVariant;
    labelOrientation?: StepperLabelOrientation;
    tracking?: boolean;
  };
 
  export type DefineProps<Steps extends Stepperize.Step[]> = Omit<
    Stepperize.StepperReturn<Steps>,
    "Scoped"
  > & {
    Stepper: {
      Provider: (
        props: Omit<Stepperize.ScopedProps<Steps>, "children"> &
          Omit<React.ComponentProps<"div">, "children"> &
          Stepper.ConfigProps & {
            children:
              | React.ReactNode
              | ((props: {
                  methods: Stepperize.Stepper<Steps>;
                }) => React.ReactNode);
          }
      ) => React.ReactElement;
      Navigation: (props: React.ComponentProps<"nav">) => React.ReactElement;
      Step: (
        props: React.ComponentProps<"button"> & {
          of: Stepperize.Get.Id<Steps>;
          icon?: React.ReactNode;
        }
      ) => React.ReactElement;
      Title: (props: AsChildProps<"h4">) => React.ReactElement;
      Description: (props: AsChildProps<"p">) => React.ReactElement;
      Panel: (props: AsChildProps<"div">) => React.ReactElement;
      Controls: (props: AsChildProps<"div">) => React.ReactElement;
    };
  };
 
  export type CircleStepIndicatorProps = {
    currentStep: number;
    totalSteps: number;
    size?: number;
    strokeWidth?: number;
  };
}
 
type AsChildProps<T extends React.ElementType> = React.ComponentProps<T> & {
  asChild?: boolean;
};
 
export { defineStepper };

Import the component

That's it! You can now use the component in your project following the component API references.

import { defineStepper } from "@/components/ui/stepper";

More components and blocks

You can find more components and blocks in this section.

Edit on GitHub

Last updated on

On this page