Stepper
A stepper component that guides users through a multi-step process with clear visual progress indicators.
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="profile" 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 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;
}
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: HTMLElement[], preventScroll = false) {
const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;
for (const candidate of candidates) {
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 ActivationMode = "automatic" | "manual";
type DataState = "inactive" | "active" | "completed";
interface DivProps extends React.ComponentProps<"div"> {
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;
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,
): 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();
},
notify: () => {
if (listenersRef.current) {
for (const cb of listenersRef.current) {
cb();
}
}
},
addStep: (value, completed, disabled) => {
const state = stateRef.current;
if (state) {
const newStep: StepState = { value, completed, disabled };
state.steps.set(value, newStep);
store.notify();
}
},
removeStep: (value) => {
const state = stateRef.current;
if (state) {
state.steps.delete(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();
}
}
},
};
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;
element: HTMLElement;
value: string;
active: boolean;
disabled: boolean;
}
interface StepperContextValue {
id: string;
dir: Direction;
orientation: Orientation;
activationMode: ActivationMode;
disabled: boolean;
nonInteractive: boolean;
loop: boolean;
onValueAdd?: (value: string) => void;
onValueRemove?: (value: string) => void;
}
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;
activationMode?: ActivationMode;
dir?: Direction;
orientation?: Orientation;
disabled?: boolean;
loop?: boolean;
nonInteractive?: boolean;
}
function StepperRoot(props: StepperRootProps) {
const {
value,
defaultValue,
onValueChange,
onValueComplete,
onValueAdd,
onValueRemove,
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),
[listenersRef, stateRef, onValueChange, onValueComplete],
);
React.useEffect(() => {
if (value !== undefined) {
store.setState("value", value);
}
}, [value, store]);
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,
onValueAdd,
onValueRemove,
}),
[
rootId,
dir,
orientation,
activationMode,
disabled,
nonInteractive,
loop,
onValueAdd,
onValueRemove,
],
);
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" ? "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 a focus provider`);
}
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 composedRefs = 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()).sort((a, b) => {
const position = a.element.compareDocumentPosition(b.element);
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 candidateNodes = candidateItems.map((item) => item.element);
focusFirst(candidateNodes, 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={composedRefs}
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);
const onValueAdd = React.useCallback(() => {
context.onValueAdd?.(itemValue);
}, [context.onValueAdd, itemValue]);
const onValueRemove = React.useCallback(() => {
context.onValueRemove?.(itemValue);
}, [context.onValueRemove, itemValue]);
useIsomorphicLayoutEffect(() => {
store.addStep(itemValue, completed, disabled);
onValueAdd();
return () => {
store.removeStep(itemValue);
onValueRemove();
};
}, [store, itemValue, completed, disabled, onValueAdd, onValueRemove]);
useIsomorphicLayoutEffect(() => {
store.setStep(itemValue, completed, disabled);
}, [store, 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>
);
}
type TriggerElement = React.ComponentRef<typeof StepperTrigger>;
interface StepperTriggerProps extends React.ComponentProps<"button"> {
asChild?: boolean;
}
function StepperTrigger(props: StepperTriggerProps) {
const { asChild, 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 || triggerProps.disabled;
const isActive = value === itemValue;
const isTabStop = focusContext.tabStopId === triggerId;
const dataState = getDataState(value, itemValue, stepState, steps);
const [triggerElement, setTriggerElement] =
React.useState<HTMLElement | null>(null);
const composedRef = useComposedRefs(ref, setTriggerElement);
const isArrowKeyPressedRef = 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);
};
}, []);
React.useEffect(() => {
if (triggerElement) {
focusContext.onItemRegister({
id: triggerId,
element: triggerElement,
value: itemValue,
active: isTabStop,
disabled: !!isDisabled,
});
if (!isDisabled) {
focusContext.onFocusableItemAdd();
}
return () => {
focusContext.onItemUnregister(triggerId);
if (!isDisabled) {
focusContext.onFocusableItemRemove();
}
};
}
}, [
triggerElement,
focusContext,
triggerId,
itemValue,
isTabStop,
isDisabled,
]);
const onClick = React.useCallback(
(event: React.MouseEvent<TriggerElement>) => {
triggerProps.onClick?.(event);
if (event.defaultPrevented) return;
if (!isDisabled && !context.nonInteractive) {
store.setState("value", itemValue);
}
},
[
isDisabled,
context.nonInteractive,
store,
itemValue,
triggerProps.onClick,
],
);
const onFocus = React.useCallback(
(event: React.FocusEvent<TriggerElement>) => {
triggerProps.onFocus?.(event);
if (event.defaultPrevented) return;
focusContext.onItemFocus(triggerId);
if (
!isActive &&
!isDisabled &&
activationMode !== "manual" &&
!context.nonInteractive
) {
store.setState("value", itemValue);
}
},
[
focusContext,
triggerId,
activationMode,
isActive,
isDisabled,
context.nonInteractive,
store,
itemValue,
triggerProps.onFocus,
],
);
const onKeyDown = React.useCallback(
(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 && triggerElement) {
triggerElement.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 candidateNodes = items.map((item) => item.element);
if (focusIntent === "last") {
candidateNodes.reverse();
} else if (focusIntent === "prev" || focusIntent === "next") {
if (focusIntent === "prev") candidateNodes.reverse();
const currentIndex = candidateNodes.indexOf(
event.currentTarget as HTMLElement,
);
candidateNodes = loop
? wrapArray(candidateNodes, currentIndex + 1)
: candidateNodes.slice(currentIndex + 1);
}
queueMicrotask(() => focusFirst(candidateNodes));
}
},
[
focusContext,
context.nonInteractive,
context.dir,
activationMode,
orientation,
loop,
isDisabled,
triggerElement,
triggerProps.onKeyDown,
],
);
const onMouseDown = React.useCallback(
(event: React.MouseEvent<TriggerElement>) => {
triggerProps.onMouseDown?.(event);
if (event.defaultPrevented) return;
if (isDisabled) {
event.preventDefault();
} else {
focusContext.onItemFocus(triggerId);
}
},
[focusContext, triggerId, isDisabled, triggerProps.onMouseDown],
);
const TriggerPrimitive = asChild ? Slot : "button";
return (
<TriggerPrimitive
id={triggerId}
role="tab"
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)}
/>
);
}
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,
//
StepperRoot as Stepper,
StepperList,
StepperItem,
StepperTrigger,
StepperIndicator,
StepperSeparator,
StepperTitle,
StepperDescription,
StepperContent,
//
useStore as useStepper,
};
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.Trigger>
<Stepper.Title />
<Stepper.Description />
<Stepper.Separator />
</Stepper.Item>
</Stepper.List>
<Stepper.Content />
</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, index) => (
<StepperItem key={step.value} value={step.value}>
<StepperTrigger className="not-last:pb-6">
<StepperIndicator>{index + 1}</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>
);
}
Controlled State
A stepper with controlled state management and navigation buttons.
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Stepper,
StepperContent,
StepperDescription,
StepperIndicator,
StepperItem,
StepperList,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/ui/stepper";
const steps = [
{
value: "personal",
title: "Personal Info",
description: "Enter your personal details",
},
{
value: "preferences",
title: "Preferences",
description: "Set your account preferences",
},
{
value: "confirmation",
title: "Confirmation",
description: "Review and confirm your settings",
},
];
export function StepperControlledDemo() {
const [currentStep, setCurrentStep] = React.useState("personal");
const currentIndex = React.useMemo(
() => steps.findIndex((step) => step.value === currentStep),
[currentStep],
);
const nextStep = React.useCallback(() => {
const nextIndex = Math.min(currentIndex + 1, steps.length - 1);
setCurrentStep(steps[nextIndex]?.value ?? "");
}, [currentIndex]);
const prevStep = React.useCallback(() => {
const prevIndex = Math.max(currentIndex - 1, 0);
setCurrentStep(steps[prevIndex]?.value ?? "");
}, [currentIndex]);
const goToStep = React.useCallback((stepValue: string) => {
setCurrentStep(stepValue);
}, []);
return (
<Stepper value={currentStep} onValueChange={goToStep}>
<StepperList>
{steps.map((step, index) => (
<StepperItem key={step.value} value={step.value} className="gap-4">
<StepperTrigger>
<StepperIndicator>{index + 1}</StepperIndicator>
<div className="flex flex-col gap-1">
<StepperTitle>{step.title}</StepperTitle>
<StepperDescription>{step.description}</StepperDescription>
</div>
</StepperTrigger>
<StepperSeparator className="mx-4" />
</StepperItem>
))}
</StepperList>
{steps.map((step) => (
<StepperContent
key={step.value}
value={step.value}
className="flex flex-col gap-4 rounded-md border bg-card p-4 text-card-foreground"
>
<div className="flex flex-col gap-px">
<h3 className="font-semibold text-lg">{step.title}</h3>
<p className="text-muted-foreground">{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>
<div className="flex justify-between">
<Button
variant="outline"
onClick={prevStep}
disabled={currentIndex === 0}
>
Previous
</Button>
<div className="text-muted-foreground text-sm">
Step {currentIndex + 1} of {steps.length}
</div>
<Button
onClick={nextStep}
disabled={currentIndex === steps.length - 1}
>
{currentIndex === steps.length - 1 ? "Complete" : "Next"}
</Button>
</div>
</StepperContent>
))}
</Stepper>
);
}
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,
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
.string()
.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 validateStep = React.useCallback(
async (stepValue: string) => {
const step = steps.find((s) => s.value === stepValue);
if (!step) return false;
const isValid = await form.trigger(step.fields);
return isValid;
},
[form],
);
const nextStep = React.useCallback(async () => {
const isValid = await validateStep(currentStep);
if (isValid) {
const nextIndex = Math.min(currentIndex + 1, steps.length - 1);
setCurrentStep(steps[nextIndex]?.value ?? "");
}
}, [currentIndex, currentStep, validateStep]);
const prevStep = React.useCallback(() => {
const prevIndex = Math.max(currentIndex - 1, 0);
setCurrentStep(steps[prevIndex]?.value ?? "");
}, [currentIndex]);
const onSubmit = React.useCallback((input: FormSchema) => {
toast.success(
<pre className="w-full">{JSON.stringify(input, null, 2)}</pre>,
);
}, []);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Stepper value={currentStep}>
<StepperList>
{steps.map((step, index) => (
<StepperItem key={step.value} value={step.value}>
<StepperTrigger>
<StepperIndicator>{index + 1}</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">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={currentIndex === 0}
>
Previous
</Button>
<div className="text-muted-foreground text-sm">
Step {currentIndex + 1} of {steps.length}
</div>
{currentIndex === steps.length - 1 ? (
<Button type="submit">Complete</Button>
) : (
<Button type="button" onClick={nextStep}>
Next
</Button>
)}
</div>
</Stepper>
</form>
</Form>
);
}
API Reference
Root
The main container component for the stepper.
Prop | Type | Default |
---|---|---|
value? | string | - |
defaultValue? | string | - |
onValueChange? | ((value: string) => void) | - |
onValueComplete? | ((value: string, completed: boolean) => void) | - |
onValueAdd? | ((value: string) => void) | - |
onValueRemove? | ((value: string) => void) | - |
activationMode? | "automatic" | "manual" | "automatic" |
dir? | Direction | "ltr" |
orientation? | "horizontal" | "vertical" | "horizontal" |
disabled? | boolean | false |
loop? | boolean | false |
nonInteractive? | boolean | false |
asChild? | boolean | false |
List
The container for stepper items, typically an ordered list.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
Item
A single step item in the stepper.
Prop | Type | Default |
---|---|---|
value | string | - |
completed? | boolean | false |
disabled? | boolean | false |
asChild? | boolean | false |
Data Attribute | Value |
---|---|
[data-state] | "inactive" | "active" | "completed" - The current state of the step. |
Trigger
The clickable trigger for each step, typically wrapping the indicator.
Prop | Type | Default |
---|---|---|
variant? | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | "ghost" |
size? | "default" | "sm" | "lg" | "icon" | null | "sm" |
asChild? | boolean | - |
Data Attribute | Value |
---|---|
[data-state] | "inactive" | "active" | "completed" |
Indicator
The visual indicator showing the step number or completion status.
Prop | Type | Default |
---|---|---|
children? | ReactNode | ((dataState: "inactive" | "active" | "completed") => ReactNode) | - |
asChild? | boolean | false |
Data Attribute | Value |
---|---|
[data-state] | "inactive" | "active" | "completed" |
Separator
The line connecting steps, showing progress between them.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
Data Attribute | Value |
---|---|
[data-state] | "inactive" | "active" | "completed" |
Title
The title text for each step.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
Description
The description text for each step.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
Content
The content area that displays for the active step.
Prop | Type | Default |
---|---|---|
value | string | - |
forceMount? | boolean | false |
asChild? | boolean | false |
Accessibility
Keyboard Interactions
Key | Description |
---|---|
Tab | Moves focus to the next focusable element. |
Shift + Tab | Moves focus to the previous focusable element. |
EnterSpace | Activates the focused step when clickable is enabled. |
ArrowLeftArrowUp | Moves focus to the previous step trigger. |
ArrowRightArrowDown | Moves focus to the next step trigger. |
Home | Moves focus to the first step trigger. |
End | Moves focus to the last step trigger. |