Dice UI
Components

Stepper

A component that guides users through a multi-step process with clear visual progress indicators.

API
import {
  Stepper,
  StepperContent,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperSeparator,
  StepperTrigger,
} from "@/components/ui/stepper";
 
const steps = [
  {
    value: "account",
    title: "Account Setup",
    description: "Create your account and verify email",
  },
  {
    value: "profile",
    title: "Profile Information",
    description: "Add your personal details and preferences",
  },
  {
    value: "payment",
    title: "Payment Details",
    description: "Set up billing and payment methods",
  },
  {
    value: "complete",
    title: "Complete Setup",
    description: "Review and finish your account setup",
  },
];
 
export function StepperDemo() {
  return (
    <Stepper defaultValue="account" className="w-full max-w-md">
      <StepperList>
        {steps.map((step) => (
          <StepperItem key={step.value} value={step.value}>
            <StepperTrigger>
              <StepperIndicator />
            </StepperTrigger>
            <StepperSeparator />
          </StepperItem>
        ))}
      </StepperList>
      {steps.map((step) => (
        <StepperContent
          key={step.value}
          value={step.value}
          className="flex flex-col items-center gap-4 rounded-md border bg-card p-4 text-card-foreground"
        >
          <div className="flex flex-col items-center gap-px text-center">
            <h3 className="font-semibold text-lg">{step.title}</h3>
            <p className="text-muted-foreground">{step.description}</p>
          </div>
          <p className="text-sm">Content for {step.title} goes here.</p>
        </StepperContent>
      ))}
    </Stepper>
  );
}

Installation

CLI

npx shadcn@latest add "https://diceui.com/r/stepper"

Manual

Install the following dependencies:

npm install @radix-ui/react-slot class-variance-authority lucide-react

Copy and paste the following code into your project.

"use client";
 
import { Slot } from "@radix-ui/react-slot";
import { Check } from "lucide-react";
import * as React from "react";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
 
const ROOT_NAME = "Stepper";
const LIST_NAME = "StepperList";
const ITEM_NAME = "StepperItem";
const TRIGGER_NAME = "StepperTrigger";
const INDICATOR_NAME = "StepperIndicator";
const SEPARATOR_NAME = "StepperSeparator";
const TITLE_NAME = "StepperTitle";
const DESCRIPTION_NAME = "StepperDescription";
const CONTENT_NAME = "StepperContent";
const PREV_TRIGGER_NAME = "StepperPrevTrigger";
const NEXT_TRIGGER_NAME = "StepperNextTrigger";
 
const ENTRY_FOCUS = "stepperFocusGroup.onEntryFocus";
const EVENT_OPTIONS = { bubbles: false, cancelable: true };
const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
 
function getId(
  id: string,
  variant: "trigger" | "content" | "title" | "description",
  value: string,
) {
  return `${id}-${variant}-${value}`;
}
 
type FocusIntent = "first" | "last" | "prev" | "next";
 
const MAP_KEY_TO_FOCUS_INTENT: Record<string, FocusIntent> = {
  ArrowLeft: "prev",
  ArrowUp: "prev",
  ArrowRight: "next",
  ArrowDown: "next",
  PageUp: "first",
  Home: "first",
  PageDown: "last",
  End: "last",
};
 
function getDirectionAwareKey(key: string, dir?: Direction) {
  if (dir !== "rtl") return key;
  return key === "ArrowLeft"
    ? "ArrowRight"
    : key === "ArrowRight"
      ? "ArrowLeft"
      : key;
}
 
type TriggerElement = React.ComponentRef<typeof StepperTrigger>;
 
function getFocusIntent(
  event: React.KeyboardEvent<TriggerElement>,
  dir?: Direction,
  orientation?: Orientation,
) {
  const key = getDirectionAwareKey(event.key, dir);
  if (orientation === "horizontal" && ["ArrowUp", "ArrowDown"].includes(key))
    return undefined;
  if (orientation === "vertical" && ["ArrowLeft", "ArrowRight"].includes(key))
    return undefined;
  return MAP_KEY_TO_FOCUS_INTENT[key];
}
 
function focusFirst(
  candidates: React.RefObject<TriggerElement | null>[],
  preventScroll = false,
) {
  const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;
  for (const candidateRef of candidates) {
    const candidate = candidateRef.current;
    if (!candidate) continue;
    if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;
    candidate.focus({ preventScroll });
    if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;
  }
}
 
function wrapArray<T>(array: T[], startIndex: number) {
  return array.map<T>(
    (_, index) => array[(startIndex + index) % array.length] as T,
  );
}
 
function useLazyRef<T>(fn: () => T) {
  const ref = React.useRef<T | null>(null);
 
  if (ref.current === null) {
    ref.current = fn();
  }
 
  return ref as React.RefObject<T>;
}
 
const useIsomorphicLayoutEffect =
  typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
 
type Direction = "ltr" | "rtl";
type Orientation = "horizontal" | "vertical";
type NavigationDirection = "next" | "prev";
type ActivationMode = "automatic" | "manual";
type DataState = "inactive" | "active" | "completed";
 
interface DivProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
interface ButtonProps extends React.ComponentProps<"button"> {
  asChild?: boolean;
}
 
function getDataState(
  value: string | undefined,
  itemValue: string,
  stepState: StepState | undefined,
  steps: Map<string, StepState>,
  variant: "item" | "separator" = "item",
): DataState {
  const stepKeys = Array.from(steps.keys());
  const currentIndex = stepKeys.indexOf(itemValue);
 
  if (stepState?.completed) return "completed";
 
  if (value === itemValue) {
    return variant === "separator" ? "inactive" : "active";
  }
 
  if (value) {
    const activeIndex = stepKeys.indexOf(value);
 
    if (activeIndex > currentIndex) return "completed";
  }
 
  return "inactive";
}
 
const DirectionContext = React.createContext<Direction | undefined>(undefined);
 
function useDirection(dirProp?: Direction): Direction {
  const contextDir = React.useContext(DirectionContext);
  return dirProp ?? contextDir ?? "ltr";
}
 
interface StepState {
  value: string;
  completed: boolean;
  disabled: boolean;
}
 
interface StoreState {
  steps: Map<string, StepState>;
  value?: string;
}
 
interface Store {
  subscribe: (callback: () => void) => () => void;
  getState: () => StoreState;
  setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
  setStateWithValidation: (
    value: string,
    direction: NavigationDirection,
  ) => Promise<boolean>;
  hasValidation: () => boolean;
  notify: () => void;
  addStep: (value: string, completed: boolean, disabled: boolean) => void;
  removeStep: (value: string) => void;
  setStep: (value: string, completed: boolean, disabled: boolean) => void;
}
 
function createStore(
  listenersRef: React.RefObject<Set<() => void>>,
  stateRef: React.RefObject<StoreState>,
  onValueChange?: (value: string) => void,
  onValueComplete?: (value: string, completed: boolean) => void,
  onValueAdd?: (value: string) => void,
  onValueRemove?: (value: string) => void,
  onValidate?: (
    value: string,
    direction: NavigationDirection,
  ) => boolean | Promise<boolean>,
): Store {
  const store: Store = {
    subscribe: (cb) => {
      if (listenersRef.current) {
        listenersRef.current.add(cb);
        return () => listenersRef.current?.delete(cb);
      }
      return () => {};
    },
    getState: () =>
      stateRef.current ?? {
        steps: new Map(),
        value: undefined,
      },
    setState: (key, value) => {
      const state = stateRef.current;
      if (!state || Object.is(state[key], value)) return;
 
      if (key === "value" && typeof value === "string") {
        state.value = value;
        onValueChange?.(value);
      } else {
        state[key] = value;
      }
 
      store.notify();
    },
    setStateWithValidation: async (value, direction) => {
      if (!onValidate) {
        store.setState("value", value);
        return true;
      }
 
      try {
        const isValid = await onValidate(value, direction);
        if (isValid) {
          store.setState("value", value);
        }
        return isValid;
      } catch {
        return false;
      }
    },
    hasValidation: () => !!onValidate,
    addStep: (value, completed, disabled) => {
      const state = stateRef.current;
      if (state) {
        const newStep: StepState = { value, completed, disabled };
        state.steps.set(value, newStep);
        onValueAdd?.(value);
        store.notify();
      }
    },
    removeStep: (value) => {
      const state = stateRef.current;
      if (state) {
        state.steps.delete(value);
        onValueRemove?.(value);
        store.notify();
      }
    },
    setStep: (value, completed, disabled) => {
      const state = stateRef.current;
      if (state) {
        const step = state.steps.get(value);
        if (step) {
          const updatedStep: StepState = { ...step, completed, disabled };
          state.steps.set(value, updatedStep);
 
          if (completed !== step.completed) {
            onValueComplete?.(value, completed);
          }
 
          store.notify();
        }
      }
    },
    notify: () => {
      if (listenersRef.current) {
        for (const cb of listenersRef.current) {
          cb();
        }
      }
    },
  };
 
  return store;
}
 
const StoreContext = React.createContext<Store | null>(null);
 
function useStoreContext(consumerName: string) {
  const context = React.useContext(StoreContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
function useStore<T>(selector: (state: StoreState) => T): T {
  const store = useStoreContext("useStore");
 
  const getSnapshot = React.useCallback(
    () => selector(store.getState()),
    [store, selector],
  );
 
  return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
 
interface ItemData {
  id: string;
  ref: React.RefObject<TriggerElement | null>;
  value: string;
  active: boolean;
  disabled: boolean;
}
 
interface StepperContextValue {
  id: string;
  dir: Direction;
  orientation: Orientation;
  activationMode: ActivationMode;
  disabled: boolean;
  nonInteractive: boolean;
  loop: boolean;
}
 
const StepperContext = React.createContext<StepperContextValue | null>(null);
 
function useStepperContext(consumerName: string) {
  const context = React.useContext(StepperContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
interface StepperRootProps extends DivProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  onValueComplete?: (value: string, completed: boolean) => void;
  onValueAdd?: (value: string) => void;
  onValueRemove?: (value: string) => void;
  onValidate?: (
    value: string,
    direction: NavigationDirection,
  ) => boolean | Promise<boolean>;
  activationMode?: ActivationMode;
  dir?: Direction;
  orientation?: Orientation;
  disabled?: boolean;
  loop?: boolean;
  nonInteractive?: boolean;
}
 
function StepperRoot(props: StepperRootProps) {
  const {
    value,
    defaultValue,
    onValueChange,
    onValueComplete,
    onValueAdd,
    onValueRemove,
    onValidate,
    id: idProp,
    dir: dirProp,
    orientation = "horizontal",
    activationMode = "automatic",
    asChild,
    disabled = false,
    nonInteractive = false,
    loop = false,
    className,
    ...rootProps
  } = props;
 
  const listenersRef = useLazyRef(() => new Set<() => void>());
  const stateRef = useLazyRef<StoreState>(() => ({
    steps: new Map(),
    value: value ?? defaultValue,
  }));
 
  const store = React.useMemo(
    () =>
      createStore(
        listenersRef,
        stateRef,
        onValueChange,
        onValueComplete,
        onValueAdd,
        onValueRemove,
        onValidate,
      ),
    [
      listenersRef,
      stateRef,
      onValueChange,
      onValueComplete,
      onValueAdd,
      onValueRemove,
      onValidate,
    ],
  );
 
  useIsomorphicLayoutEffect(() => {
    if (value !== undefined) {
      store.setState("value", value);
    }
  }, [value]);
 
  const dir = useDirection(dirProp);
 
  const id = React.useId();
 
  const rootId = idProp ?? id;
 
  const contextValue = React.useMemo<StepperContextValue>(
    () => ({
      id: rootId,
      dir,
      orientation,
      activationMode,
      disabled,
      nonInteractive,
      loop,
    }),
    [rootId, dir, orientation, activationMode, disabled, nonInteractive, loop],
  );
 
  const RootPrimitive = asChild ? Slot : "div";
 
  return (
    <StoreContext.Provider value={store}>
      <StepperContext.Provider value={contextValue}>
        <RootPrimitive
          id={rootId}
          data-disabled={disabled ? "" : undefined}
          data-orientation={orientation}
          data-slot="stepper"
          dir={dir}
          {...rootProps}
          className={cn(
            "flex gap-6",
            orientation === "horizontal" ? "w-full flex-col" : "flex-row",
            className,
          )}
        />
      </StepperContext.Provider>
    </StoreContext.Provider>
  );
}
 
interface FocusContextValue {
  tabStopId: string | null;
  onItemFocus: (tabStopId: string) => void;
  onItemShiftTab: () => void;
  onFocusableItemAdd: () => void;
  onFocusableItemRemove: () => void;
  onItemRegister: (item: ItemData) => void;
  onItemUnregister: (id: string) => void;
  getItems: () => ItemData[];
}
 
const FocusContext = React.createContext<FocusContextValue | null>(null);
 
function useFocusContext(consumerName: string) {
  const context = React.useContext(FocusContext);
  if (!context) {
    throw new Error(
      `\`${consumerName}\` must be used within \`FocusProvider\``,
    );
  }
  return context;
}
 
type ListElement = React.ComponentRef<typeof StepperList>;
 
interface StepperListProps extends DivProps {
  asChild?: boolean;
}
 
function StepperList(props: StepperListProps) {
  const { className, children, asChild, ref, ...listProps } = props;
 
  const context = useStepperContext(LIST_NAME);
  const orientation = context.orientation;
  const currentValue = useStore((state) => state.value);
 
  const [tabStopId, setTabStopId] = React.useState<string | null>(null);
  const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false);
  const [focusableItemCount, setFocusableItemCount] = React.useState(0);
  const isClickFocusRef = React.useRef(false);
  const itemsRef = React.useRef<Map<string, ItemData>>(new Map());
  const listRef = React.useRef<HTMLElement>(null);
  const composedRef = useComposedRefs(ref, listRef);
 
  const onItemFocus = React.useCallback((tabStopId: string) => {
    setTabStopId(tabStopId);
  }, []);
 
  const onItemShiftTab = React.useCallback(() => {
    setIsTabbingBackOut(true);
  }, []);
 
  const onFocusableItemAdd = React.useCallback(() => {
    setFocusableItemCount((prevCount) => prevCount + 1);
  }, []);
 
  const onFocusableItemRemove = React.useCallback(() => {
    setFocusableItemCount((prevCount) => prevCount - 1);
  }, []);
 
  const onItemRegister = React.useCallback((item: ItemData) => {
    itemsRef.current.set(item.id, item);
  }, []);
 
  const onItemUnregister = React.useCallback((id: string) => {
    itemsRef.current.delete(id);
  }, []);
 
  const getItems = React.useCallback(() => {
    return Array.from(itemsRef.current.values())
      .filter((item) => item.ref.current)
      .sort((a, b) => {
        const elementA = a.ref.current;
        const elementB = b.ref.current;
        if (!elementA || !elementB) return 0;
        const position = elementA.compareDocumentPosition(elementB);
        if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
          return -1;
        }
        if (position & Node.DOCUMENT_POSITION_PRECEDING) {
          return 1;
        }
        return 0;
      });
  }, []);
 
  const onBlur = React.useCallback(
    (event: React.FocusEvent<ListElement>) => {
      listProps.onBlur?.(event);
      if (event.defaultPrevented) return;
 
      setIsTabbingBackOut(false);
    },
    [listProps.onBlur],
  );
 
  const onFocus = React.useCallback(
    (event: React.FocusEvent<ListElement>) => {
      listProps.onFocus?.(event);
      if (event.defaultPrevented) return;
 
      const isKeyboardFocus = !isClickFocusRef.current;
      if (
        event.target === event.currentTarget &&
        isKeyboardFocus &&
        !isTabbingBackOut
      ) {
        const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS);
        event.currentTarget.dispatchEvent(entryFocusEvent);
 
        if (!entryFocusEvent.defaultPrevented) {
          const items = Array.from(itemsRef.current.values()).filter(
            (item) => !item.disabled,
          );
          const selectedItem = currentValue
            ? items.find((item) => item.value === currentValue)
            : undefined;
          const activeItem = items.find((item) => item.active);
          const currentItem = items.find((item) => item.id === tabStopId);
 
          const candidateItems = [
            selectedItem,
            activeItem,
            currentItem,
            ...items,
          ].filter(Boolean) as ItemData[];
          const candidateRefs = candidateItems.map((item) => item.ref);
          focusFirst(candidateRefs, false);
        }
      }
      isClickFocusRef.current = false;
    },
    [listProps.onFocus, isTabbingBackOut, currentValue, tabStopId],
  );
 
  const onMouseDown = React.useCallback(
    (event: React.MouseEvent<ListElement>) => {
      listProps.onMouseDown?.(event);
 
      if (event.defaultPrevented) return;
 
      isClickFocusRef.current = true;
    },
    [listProps.onMouseDown],
  );
 
  const focusContextValue = React.useMemo<FocusContextValue>(
    () => ({
      tabStopId,
      onItemFocus,
      onItemShiftTab,
      onFocusableItemAdd,
      onFocusableItemRemove,
      onItemRegister,
      onItemUnregister,
      getItems,
    }),
    [
      tabStopId,
      onItemFocus,
      onItemShiftTab,
      onFocusableItemAdd,
      onFocusableItemRemove,
      onItemRegister,
      onItemUnregister,
      getItems,
    ],
  );
 
  const ListPrimitive = asChild ? Slot : "div";
 
  return (
    <FocusContext.Provider value={focusContextValue}>
      <ListPrimitive
        role="tablist"
        aria-orientation={orientation}
        data-orientation={orientation}
        data-slot="stepper-list"
        dir={context.dir}
        tabIndex={isTabbingBackOut || focusableItemCount === 0 ? -1 : 0}
        {...listProps}
        ref={composedRef}
        className={cn(
          "flex outline-none",
          orientation === "horizontal"
            ? "flex-row items-center"
            : "flex-col items-start",
          className,
        )}
        onBlur={onBlur}
        onFocus={onFocus}
        onMouseDown={onMouseDown}
      >
        {children}
      </ListPrimitive>
    </FocusContext.Provider>
  );
}
 
interface StepperItemContextValue {
  value: string;
  stepState: StepState | undefined;
}
 
const StepperItemContext = React.createContext<StepperItemContextValue | null>(
  null,
);
 
function useStepperItemContext(consumerName: string) {
  const context = React.useContext(StepperItemContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
  }
  return context;
}
 
interface StepperItemProps extends DivProps {
  value: string;
  completed?: boolean;
  disabled?: boolean;
}
 
function StepperItem(props: StepperItemProps) {
  const {
    value: itemValue,
    completed = false,
    disabled = false,
    asChild,
    className,
    children,
    ref,
    ...itemProps
  } = props;
 
  const context = useStepperContext(ITEM_NAME);
  const store = useStoreContext(ITEM_NAME);
  const orientation = context.orientation;
  const value = useStore((state) => state.value);
 
  useIsomorphicLayoutEffect(() => {
    store.addStep(itemValue, completed, disabled);
 
    return () => {
      store.removeStep(itemValue);
    };
  }, [itemValue, completed, disabled]);
 
  useIsomorphicLayoutEffect(() => {
    store.setStep(itemValue, completed, disabled);
  }, [itemValue, completed, disabled]);
 
  const stepState = useStore((state) => state.steps.get(itemValue));
  const steps = useStore((state) => state.steps);
  const dataState = getDataState(value, itemValue, stepState, steps);
 
  const itemContextValue = React.useMemo<StepperItemContextValue>(
    () => ({
      value: itemValue,
      stepState,
    }),
    [itemValue, stepState],
  );
 
  const ItemPrimitive = asChild ? Slot : "div";
 
  return (
    <StepperItemContext.Provider value={itemContextValue}>
      <ItemPrimitive
        data-disabled={stepState?.disabled ? "" : undefined}
        data-orientation={orientation}
        data-state={dataState}
        data-slot="stepper-item"
        dir={context.dir}
        {...itemProps}
        ref={ref}
        className={cn(
          "relative flex not-last:flex-1 items-center",
          orientation === "horizontal" ? "flex-row" : "flex-col",
          className,
        )}
      >
        {children}
      </ItemPrimitive>
    </StepperItemContext.Provider>
  );
}
 
function StepperTrigger(props: ButtonProps) {
  const { asChild, disabled, className, ref, ...triggerProps } = props;
 
  const context = useStepperContext(TRIGGER_NAME);
  const itemContext = useStepperItemContext(TRIGGER_NAME);
  const store = useStoreContext(TRIGGER_NAME);
  const focusContext = useFocusContext(TRIGGER_NAME);
  const value = useStore((state) => state.value);
  const itemValue = itemContext.value;
  const stepState = useStore((state) => state.steps.get(itemValue));
  const activationMode = context.activationMode;
  const orientation = context.orientation;
  const loop = context.loop;
 
  const steps = useStore((state) => state.steps);
  const stepIndex = Array.from(steps.keys()).indexOf(itemValue);
 
  const stepPosition = stepIndex + 1;
  const stepCount = steps.size;
 
  const triggerId = getId(context.id, "trigger", itemValue);
  const contentId = getId(context.id, "content", itemValue);
  const titleId = getId(context.id, "title", itemValue);
  const descriptionId = getId(context.id, "description", itemValue);
 
  const isDisabled = context.disabled || stepState?.disabled || disabled;
  const isActive = value === itemValue;
  const isTabStop = focusContext.tabStopId === triggerId;
  const dataState = getDataState(value, itemValue, stepState, steps);
 
  const triggerRef = React.useRef<TriggerElement>(null);
  const composedRef = useComposedRefs(ref, triggerRef);
  const isArrowKeyPressedRef = React.useRef(false);
  const isMouseClickRef = React.useRef(false);
 
  React.useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      if (ARROW_KEYS.includes(event.key)) {
        isArrowKeyPressedRef.current = true;
      }
    }
    function onKeyUp() {
      isArrowKeyPressedRef.current = false;
    }
    document.addEventListener("keydown", onKeyDown);
    document.addEventListener("keyup", onKeyUp);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
      document.removeEventListener("keyup", onKeyUp);
    };
  }, []);
 
  useIsomorphicLayoutEffect(() => {
    focusContext.onItemRegister({
      id: triggerId,
      ref: triggerRef,
      value: itemValue,
      active: isTabStop,
      disabled: !!isDisabled,
    });
 
    if (!isDisabled) {
      focusContext.onFocusableItemAdd();
    }
 
    return () => {
      focusContext.onItemUnregister(triggerId);
      if (!isDisabled) {
        focusContext.onFocusableItemRemove();
      }
    };
  }, [focusContext, triggerId, itemValue, isTabStop, isDisabled]);
 
  const onClick = React.useCallback(
    async (event: React.MouseEvent<TriggerElement>) => {
      triggerProps.onClick?.(event);
      if (event.defaultPrevented) return;
 
      if (!isDisabled && !context.nonInteractive) {
        const currentStepIndex = Array.from(steps.keys()).indexOf(value ?? "");
        const targetStepIndex = Array.from(steps.keys()).indexOf(itemValue);
        const direction = targetStepIndex > currentStepIndex ? "next" : "prev";
 
        await store.setStateWithValidation(itemValue, direction);
      }
    },
    [
      isDisabled,
      context.nonInteractive,
      store,
      itemValue,
      value,
      steps,
      triggerProps.onClick,
    ],
  );
 
  const onFocus = React.useCallback(
    async (event: React.FocusEvent<TriggerElement>) => {
      triggerProps.onFocus?.(event);
      if (event.defaultPrevented) return;
 
      focusContext.onItemFocus(triggerId);
 
      const isKeyboardFocus = !isMouseClickRef.current;
 
      if (
        !isActive &&
        !isDisabled &&
        activationMode !== "manual" &&
        !context.nonInteractive &&
        isKeyboardFocus
      ) {
        const currentStepIndex = Array.from(steps.keys()).indexOf(value || "");
        const targetStepIndex = Array.from(steps.keys()).indexOf(itemValue);
        const direction = targetStepIndex > currentStepIndex ? "next" : "prev";
 
        await store.setStateWithValidation(itemValue, direction);
      }
 
      isMouseClickRef.current = false;
    },
    [
      focusContext,
      triggerId,
      activationMode,
      isActive,
      isDisabled,
      context.nonInteractive,
      store,
      itemValue,
      value,
      steps,
      triggerProps.onFocus,
    ],
  );
 
  const onKeyDown = React.useCallback(
    async (event: React.KeyboardEvent<TriggerElement>) => {
      triggerProps.onKeyDown?.(event);
      if (event.defaultPrevented) return;
 
      if (event.key === "Enter" && context.nonInteractive) {
        event.preventDefault();
        return;
      }
 
      if (
        (event.key === "Enter" || event.key === " ") &&
        activationMode === "manual" &&
        !context.nonInteractive
      ) {
        event.preventDefault();
        if (!isDisabled && triggerRef.current) {
          triggerRef.current.click();
        }
        return;
      }
 
      if (event.key === "Tab" && event.shiftKey) {
        focusContext.onItemShiftTab();
        return;
      }
 
      if (event.target !== event.currentTarget) return;
 
      const focusIntent = getFocusIntent(event, context.dir, orientation);
 
      if (focusIntent !== undefined) {
        if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey)
          return;
        event.preventDefault();
 
        const items = focusContext.getItems().filter((item) => !item.disabled);
        let candidateRefs = items.map((item) => item.ref);
 
        if (focusIntent === "last") {
          candidateRefs.reverse();
        } else if (focusIntent === "prev" || focusIntent === "next") {
          if (focusIntent === "prev") candidateRefs.reverse();
          const currentIndex = candidateRefs.findIndex(
            (ref) => ref.current === event.currentTarget,
          );
          candidateRefs = loop
            ? wrapArray(candidateRefs, currentIndex + 1)
            : candidateRefs.slice(currentIndex + 1);
        }
 
        if (store.hasValidation() && candidateRefs.length > 0) {
          const nextRef = candidateRefs[0];
          const nextElement = nextRef?.current;
          const nextItem = items.find(
            (item) => item.ref.current === nextElement,
          );
 
          if (nextItem && nextItem.value !== itemValue) {
            const currentStepIndex = Array.from(steps.keys()).indexOf(
              value || "",
            );
            const targetStepIndex = Array.from(steps.keys()).indexOf(
              nextItem.value,
            );
            const direction: NavigationDirection =
              targetStepIndex > currentStepIndex ? "next" : "prev";
 
            if (direction === "next") {
              const isValid = await store.setStateWithValidation(
                nextItem.value,
                direction,
              );
              if (!isValid) return;
            } else {
              store.setState("value", nextItem.value);
            }
 
            queueMicrotask(() => nextElement?.focus());
            return;
          }
        }
 
        queueMicrotask(() => focusFirst(candidateRefs));
      }
    },
    [
      focusContext,
      context.nonInteractive,
      context.dir,
      activationMode,
      orientation,
      loop,
      isDisabled,
      triggerProps.onKeyDown,
      store,
      itemValue,
      value,
      steps,
    ],
  );
 
  const onMouseDown = React.useCallback(
    (event: React.MouseEvent<TriggerElement>) => {
      triggerProps.onMouseDown?.(event);
      if (event.defaultPrevented) return;
 
      isMouseClickRef.current = true;
 
      if (isDisabled) {
        event.preventDefault();
      } else {
        focusContext.onItemFocus(triggerId);
      }
    },
    [focusContext, triggerId, isDisabled, triggerProps.onMouseDown],
  );
 
  const TriggerPrimitive = asChild ? Slot : "button";
 
  return (
    <TriggerPrimitive
      id={triggerId}
      role="tab"
      type="button"
      aria-controls={contentId}
      aria-current={isActive ? "step" : undefined}
      aria-describedby={`${titleId} ${descriptionId}`}
      aria-posinset={stepPosition}
      aria-selected={isActive}
      aria-setsize={stepCount}
      data-disabled={isDisabled ? "" : undefined}
      data-state={dataState}
      data-slot="stepper-trigger"
      disabled={isDisabled}
      tabIndex={isTabStop ? 0 : -1}
      {...triggerProps}
      ref={composedRef}
      className={cn(
        "inline-flex items-center justify-center gap-3 rounded-md text-left outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
        "not-has-[[data-slot=description]]:rounded-full not-has-[[data-slot=title]]:rounded-full",
        className,
      )}
      onClick={onClick}
      onFocus={onFocus}
      onKeyDown={onKeyDown}
      onMouseDown={onMouseDown}
    />
  );
}
 
interface StepperIndicatorProps extends Omit<DivProps, "children"> {
  children?: React.ReactNode | ((dataState: DataState) => React.ReactNode);
}
 
function StepperIndicator(props: StepperIndicatorProps) {
  const { className, children, asChild, ref, ...indicatorProps } = props;
  const context = useStepperContext(INDICATOR_NAME);
  const itemContext = useStepperItemContext(INDICATOR_NAME);
  const value = useStore((state) => state.value);
  const itemValue = itemContext.value;
  const stepState = useStore((state) => state.steps.get(itemValue));
  const steps = useStore((state) => state.steps);
 
  const stepPosition = Array.from(steps.keys()).indexOf(itemValue) + 1;
 
  const dataState = getDataState(value, itemValue, stepState, steps);
 
  const IndicatorPrimitive = asChild ? Slot : "div";
 
  return (
    <IndicatorPrimitive
      data-state={dataState}
      data-slot="stepper-indicator"
      dir={context.dir}
      {...indicatorProps}
      ref={ref}
      className={cn(
        "flex size-7 shrink-0 items-center justify-center rounded-full border-2 border-muted bg-background font-medium text-muted-foreground text-sm transition-colors data-[state=active]:border-primary data-[state=completed]:border-primary data-[state=active]:bg-primary data-[state=completed]:bg-primary data-[state=active]:text-primary-foreground data-[state=completed]:text-primary-foreground",
        className,
      )}
    >
      {typeof children === "function" ? (
        children(dataState)
      ) : children ? (
        children
      ) : dataState === "completed" ? (
        <Check className="size-4" />
      ) : (
        stepPosition
      )}
    </IndicatorPrimitive>
  );
}
 
interface StepperSeparatorProps extends DivProps {
  forceMount?: boolean;
}
 
function StepperSeparator(props: StepperSeparatorProps) {
  const {
    className,
    asChild,
    forceMount = false,
    ref,
    ...separatorProps
  } = props;
 
  const context = useStepperContext(SEPARATOR_NAME);
  const itemContext = useStepperItemContext(SEPARATOR_NAME);
  const value = useStore((state) => state.value);
  const orientation = context.orientation;
 
  const steps = useStore((state) => state.steps);
  const stepIndex = Array.from(steps.keys()).indexOf(itemContext.value);
 
  const isLastStep = stepIndex === steps.size - 1;
 
  if (isLastStep && !forceMount) {
    return null;
  }
 
  const dataState = getDataState(
    value,
    itemContext.value,
    itemContext.stepState,
    steps,
    "separator",
  );
 
  const SeparatorPrimitive = asChild ? Slot : "div";
 
  return (
    <SeparatorPrimitive
      role="separator"
      aria-hidden="true"
      aria-orientation={orientation}
      data-orientation={orientation}
      data-state={dataState}
      data-slot="stepper-separator"
      dir={context.dir}
      {...separatorProps}
      ref={ref}
      className={cn(
        "bg-border transition-colors data-[state=active]:bg-primary data-[state=completed]:bg-primary",
        orientation === "horizontal" ? "h-px flex-1" : "h-10 w-px",
        className,
      )}
    />
  );
}
 
interface StepperTitleProps extends React.ComponentProps<"span"> {
  asChild?: boolean;
}
 
function StepperTitle(props: StepperTitleProps) {
  const { className, asChild, ref, ...titleProps } = props;
 
  const context = useStepperContext(TITLE_NAME);
  const itemContext = useStepperItemContext(TITLE_NAME);
 
  const titleId = getId(context.id, "title", itemContext.value);
 
  const TitlePrimitive = asChild ? Slot : "span";
 
  return (
    <TitlePrimitive
      id={titleId}
      data-slot="title"
      dir={context.dir}
      {...titleProps}
      ref={ref}
      className={cn("font-medium text-sm", className)}
    />
  );
}
 
interface StepperDescriptionProps extends React.ComponentProps<"span"> {
  asChild?: boolean;
}
 
function StepperDescription(props: StepperDescriptionProps) {
  const { className, asChild, ref, ...descriptionProps } = props;
  const context = useStepperContext(DESCRIPTION_NAME);
  const itemContext = useStepperItemContext(DESCRIPTION_NAME);
 
  const descriptionId = getId(context.id, "description", itemContext.value);
 
  const DescriptionPrimitive = asChild ? Slot : "span";
 
  return (
    <DescriptionPrimitive
      id={descriptionId}
      data-slot="description"
      dir={context.dir}
      {...descriptionProps}
      ref={ref}
      className={cn("text-muted-foreground text-xs", className)}
    />
  );
}
 
interface StepperContentProps extends DivProps {
  value: string;
  forceMount?: boolean;
}
 
function StepperContent(props: StepperContentProps) {
  const {
    value: valueProp,
    asChild,
    forceMount = false,
    ref,
    className,
    ...contentProps
  } = props;
 
  const context = useStepperContext(CONTENT_NAME);
  const value = useStore((state) => state.value);
 
  const contentId = getId(context.id, "content", valueProp);
  const triggerId = getId(context.id, "trigger", valueProp);
 
  if (valueProp !== value && !forceMount) return null;
 
  const ContentPrimitive = asChild ? Slot : "div";
 
  return (
    <ContentPrimitive
      id={contentId}
      role="tabpanel"
      aria-labelledby={triggerId}
      data-slot="stepper-content"
      dir={context.dir}
      {...contentProps}
      ref={ref}
      className={cn("flex-1 outline-none", className)}
    />
  );
}
 
function StepperPrevTrigger(props: ButtonProps) {
  const { asChild, disabled, ...prevTriggerProps } = props;
 
  const store = useStoreContext(PREV_TRIGGER_NAME);
  const value = useStore((state) => state.value);
  const steps = useStore((state) => state.steps);
 
  const stepKeys = Array.from(steps.keys());
  const currentIndex = value ? stepKeys.indexOf(value) : -1;
  const isDisabled = disabled || currentIndex <= 0;
 
  const onClick = React.useCallback(
    async (event: React.MouseEvent<HTMLButtonElement>) => {
      prevTriggerProps.onClick?.(event);
      if (event.defaultPrevented || isDisabled) return;
 
      const prevIndex = Math.max(currentIndex - 1, 0);
      const prevStepValue = stepKeys[prevIndex];
 
      if (prevStepValue) {
        store.setState("value", prevStepValue);
      }
    },
    [prevTriggerProps.onClick, isDisabled, currentIndex, stepKeys, store],
  );
 
  const PrevTriggerPrimitive = asChild ? Slot : "button";
 
  return (
    <PrevTriggerPrimitive
      type="button"
      data-slot="stepper-prev-trigger"
      disabled={isDisabled}
      {...prevTriggerProps}
      onClick={onClick}
    />
  );
}
 
function StepperNextTrigger(props: ButtonProps) {
  const { asChild, disabled, ...nextTriggerProps } = props;
 
  const store = useStoreContext(NEXT_TRIGGER_NAME);
  const value = useStore((state) => state.value);
  const steps = useStore((state) => state.steps);
 
  const stepKeys = Array.from(steps.keys());
  const currentIndex = value ? stepKeys.indexOf(value) : -1;
  const isDisabled = disabled || currentIndex >= stepKeys.length - 1;
 
  const onClick = React.useCallback(
    async (event: React.MouseEvent<HTMLButtonElement>) => {
      nextTriggerProps.onClick?.(event);
      if (event.defaultPrevented || isDisabled) return;
 
      const nextIndex = Math.min(currentIndex + 1, stepKeys.length - 1);
      const nextStepValue = stepKeys[nextIndex];
 
      if (nextStepValue) {
        await store.setStateWithValidation(nextStepValue, "next");
      }
    },
    [nextTriggerProps.onClick, isDisabled, currentIndex, stepKeys, store],
  );
 
  const NextTriggerPrimitive = asChild ? Slot : "button";
 
  return (
    <NextTriggerPrimitive
      type="button"
      data-slot="stepper-next-trigger"
      disabled={isDisabled}
      {...nextTriggerProps}
      onClick={onClick}
    />
  );
}
 
export {
  StepperRoot as Root,
  StepperList as List,
  StepperItem as Item,
  StepperTrigger as Trigger,
  StepperIndicator as ItemIndicator,
  StepperSeparator as Separator,
  StepperTitle as Title,
  StepperDescription as Description,
  StepperContent as Content,
  StepperPrevTrigger as PrevTrigger,
  StepperNextTrigger as NextTrigger,
  //
  StepperRoot as Stepper,
  StepperList,
  StepperItem,
  StepperTrigger,
  StepperIndicator,
  StepperSeparator,
  StepperTitle,
  StepperDescription,
  StepperContent,
  StepperPrevTrigger,
  StepperNextTrigger,
  //
  useStore as useStepper,
  //
  type StepperRootProps as StepperProps,
};

Layout

Import the parts, and compose them together.

import * as Stepper from "@/components/ui/stepper";

<Stepper.Root>
  <Stepper.List>
    <Stepper.Item >
      <Stepper.Trigger>
        <Stepper.Indicator />
        <Stepper.Title />
        <Stepper.Description />
      </Stepper.Trigger>
      <Stepper.Separator />
    </Stepper.Item>
  </Stepper.List>
  <Stepper.Content />
  <Stepper.PrevTrigger />
  <Stepper.NextTrigger />
</Stepper.Root>

Examples

Vertical Layout

A stepper with vertical orientation for compact layouts.

import {
  Stepper,
  StepperContent,
  StepperDescription,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@/components/ui/stepper";
 
const steps = [
  {
    value: "placed",
    title: "Order Placed",
    description: "Your order has been successfully placed",
  },
  {
    value: "processing",
    title: "Processing",
    description: "We're preparing your items for shipment",
  },
  {
    value: "shipped",
    title: "Shipped",
    description: "Your order is on its way to you",
  },
  {
    value: "delivered",
    title: "Delivered",
    description: "Order delivered to your address",
  },
];
 
export function StepperVerticalDemo() {
  return (
    <Stepper defaultValue="shipped" orientation="vertical">
      <StepperList>
        {steps.map((step) => (
          <StepperItem key={step.value} value={step.value}>
            <StepperTrigger className="not-last:pb-6">
              <StepperIndicator />
              <div className="flex flex-col gap-1">
                <StepperTitle>{step.title}</StepperTitle>
                <StepperDescription>{step.description}</StepperDescription>
              </div>
            </StepperTrigger>
            <StepperSeparator className="-order-1 -translate-x-1/2 -z-10 absolute inset-y-0 top-5 left-3.5 h-full" />
          </StepperItem>
        ))}
      </StepperList>
      {steps.map((step) => (
        <StepperContent
          key={step.value}
          value={step.value}
          className="flex flex-col gap-4 rounded-lg border bg-card p-6 text-card-foreground"
        >
          <div className="flex flex-col gap-px">
            <h4 className="font-semibold">{step.title}</h4>
            <p className="text-muted-foreground text-sm">{step.description}</p>
          </div>
          <p className="text-sm">
            This is the content for {step.title}. You can add forms,
            information, or any other content here.
          </p>
        </StepperContent>
      ))}
    </Stepper>
  );
}

With Validation

Use the onValidate prop to validate the current step before moving to the next one.

"use client";
 
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
  Stepper,
  StepperContent,
  StepperDescription,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperNextTrigger,
  StepperPrevTrigger,
  type StepperProps,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@/components/ui/stepper";
 
const formSchema = z.object({
  username: z
    .string()
    .min(3, "Username must be at least 3 characters")
    .regex(
      /^[a-zA-Z0-9_]+$/,
      "Username can only contain letters, numbers, and underscores",
    ),
  email: z.email("Please enter a valid email address"),
  firstName: z.string().min(1, "First name is required"),
  lastName: z.string().min(1, "Last name is required"),
  bio: z.string().min(10, "Bio must be at least 10 characters"),
});
 
type FormSchema = z.infer<typeof formSchema>;
 
const steps = [
  {
    value: "account",
    title: "Account Setup",
    description: "Create your account",
    fields: ["username", "email"] as const,
  },
  {
    value: "profile",
    title: "Profile Info",
    description: "Complete your profile",
    fields: ["firstName", "lastName", "bio"] as const,
  },
  {
    value: "review",
    title: "Review",
    description: "Review your information",
    fields: [] as const,
  },
];
 
export function StepperValidationDemo() {
  const [currentStep, setCurrentStep] = React.useState("account");
 
  const form = useForm<FormSchema>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: "",
      email: "",
      firstName: "",
      lastName: "",
      bio: "",
    },
  });
 
  const currentIndex = React.useMemo(
    () => steps.findIndex((step) => step.value === currentStep),
    [currentStep],
  );
 
  const onValidate: NonNullable<StepperProps["onValidate"]> = React.useCallback(
    async (_value, direction) => {
      if (direction === "prev") return true;
 
      const currentStepData = steps.find((s) => s.value === currentStep);
      if (!currentStepData) return true;
 
      const isValid = await form.trigger(currentStepData.fields);
 
      if (!isValid) {
        toast.info("Please complete all required fields to continue", {
          description: "Fix the validation errors and try again.",
        });
      }
 
      return isValid;
    },
    [form, currentStep],
  );
 
  const onValueChange = React.useCallback((value: string) => {
    setCurrentStep(value);
  }, []);
 
  const onSubmit = React.useCallback((input: FormSchema) => {
    toast.success(
      <pre className="w-full">{JSON.stringify(input, null, 2)}</pre>,
    );
  }, []);
 
  return (
    <Form {...form}>
      <form className="w-full" onSubmit={form.handleSubmit(onSubmit)}>
        <Stepper
          value={currentStep}
          onValueChange={onValueChange}
          onValidate={onValidate}
        >
          <StepperList>
            {steps.map((step) => (
              <StepperItem key={step.value} value={step.value}>
                <StepperTrigger>
                  <StepperIndicator />
                  <div className="flex flex-col gap-1">
                    <StepperTitle>{step.title}</StepperTitle>
                    <StepperDescription>{step.description}</StepperDescription>
                  </div>
                </StepperTrigger>
                <StepperSeparator className="mx-4" />
              </StepperItem>
            ))}
          </StepperList>
          <StepperContent
            value="account"
            className="flex flex-col gap-4 rounded-md border bg-card p-4 text-card-foreground"
          >
            <FormField
              control={form.control}
              name="username"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Username</FormLabel>
                  <FormControl>
                    <Input placeholder="Enter username" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email</FormLabel>
                  <FormControl>
                    <Input type="email" placeholder="Enter email" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </StepperContent>
          <StepperContent
            value="profile"
            className="flex flex-col gap-4 rounded-md border bg-card p-4 text-card-foreground"
          >
            <div className="grid grid-cols-2 gap-4">
              <FormField
                control={form.control}
                name="firstName"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>First Name</FormLabel>
                    <FormControl>
                      <Input placeholder="Enter first name" {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                control={form.control}
                name="lastName"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Last Name</FormLabel>
                    <FormControl>
                      <Input placeholder="Enter last name" {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
            </div>
            <FormField
              control={form.control}
              name="bio"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Bio</FormLabel>
                  <FormControl>
                    <Textarea
                      placeholder="Tell us about yourself..."
                      className="min-h-[80px]"
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </StepperContent>
          <StepperContent
            value="review"
            className="grid grid-cols-2 gap-4 rounded-md border bg-card p-4 text-card-foreground lg:grid-cols-3"
          >
            <div className="flex flex-col gap-1 rounded-md border p-2">
              <span className="font-medium text-sm">Username</span>
              <p className="text-sm">
                {form.watch("username") ?? "Not provided"}
              </p>
            </div>
            <div className="flex flex-col gap-1 rounded-md border p-2">
              <span className="font-medium text-sm">Email</span>
              <p className="text-sm">{form.watch("email") ?? "Not provided"}</p>
            </div>
            <div className="flex flex-col gap-1 rounded-md border p-2">
              <span className="font-medium text-sm">First Name</span>
              <p className="text-sm">
                {form.watch("firstName") ?? "Not provided"}
              </p>
            </div>
            <div className="flex flex-col gap-1 rounded-md border p-2">
              <span className="font-medium text-sm">Last Name</span>
              <p className="text-sm">
                {form.watch("lastName") ?? "Not provided"}
              </p>
            </div>
            <div className="flex flex-col gap-1 rounded-md border p-2">
              <span className="font-medium text-sm">Bio</span>
              <p className="text-sm">{form.watch("bio") ?? "Not provided"}</p>
            </div>
          </StepperContent>
          <div className="flex justify-between">
            <StepperPrevTrigger asChild>
              <Button type="button" variant="outline">
                Previous
              </Button>
            </StepperPrevTrigger>
            <div className="text-muted-foreground text-sm">
              Step {currentIndex + 1} of {steps.length}
            </div>
            {currentIndex === steps.length - 1 ? (
              <Button type="submit">Complete Setup</Button>
            ) : (
              <StepperNextTrigger asChild>
                <Button>Next</Button>
              </StepperNextTrigger>
            )}
          </div>
        </Stepper>
      </form>
    </Form>
  );
}

With Form

A stepper integrated with form validation, showing step-by-step form completion.

"use client";
 
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
  Stepper,
  StepperContent,
  StepperDescription,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperNextTrigger,
  StepperPrevTrigger,
  type StepperProps,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@/components/ui/stepper";
 
const formSchema = z.object({
  firstName: z.string().min(2, "First name must be at least 2 characters"),
  lastName: z.string().min(2, "Last name must be at least 2 characters"),
  email: z.email("Please enter a valid email address"),
  bio: z.string().min(10, "Bio must be at least 10 characters"),
  company: z.string().min(2, "Company name must be at least 2 characters"),
  website: z.url("Please enter a valid URL").optional().or(z.literal("")),
});
 
type FormSchema = z.infer<typeof formSchema>;
 
const steps = [
  {
    value: "personal",
    title: "Personal Details",
    description: "Enter your basic information",
    fields: ["firstName", "lastName", "email"] as const,
  },
  {
    value: "about",
    title: "About You",
    description: "Tell us more about yourself",
    fields: ["bio"] as const,
  },
  {
    value: "professional",
    title: "Professional Info",
    description: "Add your professional details",
    fields: ["company", "website"] as const,
  },
];
 
export function StepperFormDemo() {
  const [currentStep, setCurrentStep] = React.useState("personal");
 
  const form = useForm<FormSchema>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      firstName: "",
      lastName: "",
      email: "",
      bio: "",
      company: "",
      website: "",
    },
  });
 
  const currentIndex = steps.findIndex((step) => step.value === currentStep);
 
  const onValidate: NonNullable<StepperProps["onValidate"]> = React.useCallback(
    async (_value, direction) => {
      if (direction === "prev") return true;
 
      const currentStepData = steps.find((s) => s.value === currentStep);
      if (!currentStepData) return true;
 
      const isValid = await form.trigger(currentStepData.fields);
 
      if (!isValid) {
        toast.info("Please complete all required fields to continue");
      }
 
      return isValid;
    },
    [form, currentStep],
  );
 
  const onValueChange = React.useCallback((value: string) => {
    setCurrentStep(value);
  }, []);
 
  const onSubmit = React.useCallback((input: FormSchema) => {
    toast.success(
      <pre className="w-full">{JSON.stringify(input, null, 2)}</pre>,
    );
  }, []);
 
  return (
    <Form {...form}>
      <form className="w-full" onSubmit={form.handleSubmit(onSubmit)}>
        <Stepper
          value={currentStep}
          onValueChange={onValueChange}
          onValidate={onValidate}
        >
          <StepperList>
            {steps.map((step) => (
              <StepperItem key={step.value} value={step.value}>
                <StepperTrigger>
                  <StepperIndicator />
                  <div className="flex flex-col gap-px">
                    <StepperTitle>{step.title}</StepperTitle>
                    <StepperDescription>{step.description}</StepperDescription>
                  </div>
                </StepperTrigger>
                <StepperSeparator className="mx-4" />
              </StepperItem>
            ))}
          </StepperList>
          <StepperContent value="personal">
            <div className="flex flex-col gap-4">
              <div className="grid grid-cols-2 gap-4">
                <FormField
                  control={form.control}
                  name="firstName"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>First Name</FormLabel>
                      <FormControl>
                        <Input placeholder="John" {...field} />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />
                <FormField
                  control={form.control}
                  name="lastName"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Last Name</FormLabel>
                      <FormControl>
                        <Input placeholder="Doe" {...field} />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />
              </div>
              <FormField
                control={form.control}
                name="email"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Email</FormLabel>
                    <FormControl>
                      <Input placeholder="[email protected]" {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
            </div>
          </StepperContent>
          <StepperContent value="about">
            <FormField
              control={form.control}
              name="bio"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Bio</FormLabel>
                  <FormControl>
                    <Textarea
                      placeholder="Tell us about yourself..."
                      className="min-h-[120px]"
                      {...field}
                    />
                  </FormControl>
                  <FormDescription>
                    Write a brief description about yourself.
                  </FormDescription>
                  <FormMessage />
                </FormItem>
              )}
            />
          </StepperContent>
          <StepperContent value="professional">
            <div className="flex flex-col gap-4">
              <FormField
                control={form.control}
                name="company"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Company</FormLabel>
                    <FormControl>
                      <Input placeholder="Acme Inc." {...field} />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              <FormField
                control={form.control}
                name="website"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Website</FormLabel>
                    <FormControl>
                      <Input placeholder="https://example.com" {...field} />
                    </FormControl>
                    <FormDescription>
                      Optional: Your personal or company website.
                    </FormDescription>
                    <FormMessage />
                  </FormItem>
                )}
              />
            </div>
          </StepperContent>
          <div className="mt-4 flex justify-between">
            <StepperPrevTrigger asChild>
              <Button variant="outline">Previous</Button>
            </StepperPrevTrigger>
            <div className="text-muted-foreground text-sm">
              Step {currentIndex + 1} of {steps.length}
            </div>
            {currentIndex === steps.length - 1 ? (
              <Button type="submit">Complete</Button>
            ) : (
              <StepperNextTrigger asChild>
                <Button>Next</Button>
              </StepperNextTrigger>
            )}
          </div>
        </Stepper>
      </form>
    </Form>
  );
}

API Reference

Root

The main container component for the stepper.

Prop

Type

List

The container for stepper items, typically an ordered list.

Prop

Type

Item

A single step item in the stepper.

Prop

Type

Data AttributeValue
[data-state]"inactive" | "active" | "completed"

Trigger

The clickable trigger for each step, typically wrapping the indicator.

Prop

Type

Data AttributeValue
[data-state]"inactive" | "active" | "completed"

Indicator

The visual indicator showing the step number or completion status.

Prop

Type

Data AttributeValue
[data-state]"inactive" | "active" | "completed"

Separator

The line connecting steps, showing progress between them.

Prop

Type

Data AttributeValue
[data-state]"inactive" | "active" | "completed"

Title

The title text for each step.

Prop

Type

Description

The description text for each step.

Prop

Type

Content

The content area that displays for the active step.

Prop

Type

PrevTrigger

A navigation button that moves to the previous step. Automatically disabled on the first step and skips validation when navigating backwards.

Prop

Type

NextTrigger

A navigation button that moves to the next step. Automatically disabled on the last step and respects validation rules when navigating forwards.

Prop

Type

Accessibility

Keyboard Interactions

KeyDescription
TabMoves focus to the next focusable element.
Shift + TabMoves focus to the previous focusable element.
EnterSpaceActivates the focused step when clickable is enabled.
ArrowLeftArrowUpMoves focus to the previous step trigger.
ArrowRightArrowDownMoves focus to the next step trigger.
HomeMoves focus to the first step trigger.
EndMoves focus to the last step trigger.