stepper
blockstepper-intent-onboardingsource: ok · refreshed
Stepper Intent Onboarding
An intent-driven onboarding block with hidden Stepper state, intent routing, profile validation, interests, async setup, and a signed-in completion state.
npx shadcn add "https://francozeta-stepper.vercel.app/stepper-intent-onboarding.json"free
Where it goes
statsonboardingkanbanprofile
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@radix-ui/react-slot
iconslucide-reactreact-icons
formsreact-hook-formzod
Registry dependencies
buttoncheckboxfieldinputseparator
Files3 · source cached 10/8/2026, 10:48:01 AM
registry/default/ui/stepper.tsx
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
type StepperOrientation = "horizontal" | "vertical";
type StepperValue = string;
type StepperStepPosition = "previous" | "current" | "next";
type StepperStepState =
| "inactive"
| "active"
| "completed"
| "disabled"
| "error";
type StepperStepInput<TValue extends StepperValue = StepperValue> = {
value: TValue;
disabled?: boolean;
};
type StepperStep<TValue extends StepperValue = StepperValue> = {
value: TValue;
disabled: boolean;
};
type StepperStepsValue<TSteps extends readonly StepperStepInput[]> =
TSteps[number]["value"];
type RegisteredStep<TValue extends StepperValue = StepperValue> =
StepperStep<TValue> & {
id: string;
};
type StepperNavigationGuard = () => boolean | Promise<boolean>;
type StepperApi<TValue extends StepperValue = StepperValue> = {
value: TValue | undefined;
orientation: StepperOrientation;
steps: StepperStep<TValue>[];
currentIndex: number;
totalSteps: number;
setValue: (value: TValue) => void;
getStepIndex: (value: TValue) => number;
canGoPrevious: boolean;
canGoNext: boolean;
goPrevious: () => void;
goNext: () => void;
};
type StepperItemApi<TValue extends StepperValue = StepperValue> = {
value: TValue;
index: number;
disabled: boolean;
completed: boolean;
isActive: boolean;
stepState: StepperStepState;
stepPosition: StepperStepPosition;
orientation: StepperOrientation;
setValue: (value: TValue) => void;
triggerId: string;
contentId: string;
};
type StepperContextValue<TValue extends StepperValue = StepperValue> =
StepperApi<TValue> & {
isExplicitMode: boolean;
transitionFromIndex: number;
transitionToIndex: number;
registerStep: (step: RegisteredStep) => void;
unregisterStep: (id: string) => void;
getTriggerId: (value: TValue) => string;
getContentId: (value: TValue) => string;
};
type StepperItemContextValue<TValue extends StepperValue = StepperValue> =
StepperItemApi<TValue>;
type StepperProps<TValue extends StepperValue = StepperValue> =
React.ComponentPropsWithoutRef<"div"> & {
value?: TValue;
defaultValue?: TValue;
onValueChange?: (value: TValue) => void;
orientation?: StepperOrientation;
steps?: readonly StepperStepInput<TValue>[];
};
type StepperPropsWithSteps<
TSteps extends readonly StepperStepInput[],
> = Omit<StepperProps<StepperStepsValue<TSteps>>, "steps"> & {
steps: TSteps;
};
type StepperPropsWithoutSteps<
TValue extends StepperValue = StepperValue,
> = Omit<StepperProps<TValue>, "steps"> & {
steps?: undefined;
};
type StepperListProps = React.ComponentPropsWithoutRef<"ol">;
type StepperItemProps<TValue extends StepperValue = StepperValue> = Omit<
React.ComponentPropsWithoutRef<"li">,
"value"
> & {
value: TValue;
completed?: boolean;
defaultTrigger?: boolean;
disabled?: boolean;
error?: boolean;
separator?: boolean;
};
type StepperTriggerProps = React.ComponentPropsWithoutRef<"button"> & {
asChild?: boolean;
};
type StepperIndicatorProps = React.ComponentPropsWithoutRef<"span">;
type StepperLabelProps = React.ComponentPropsWithoutRef<"span">;
type StepperDescriptionProps = React.ComponentPropsWithoutRef<"span">;
type StepperSeparatorProps = React.ComponentPropsWithoutRef<"span">;
type StepperContentProps<TValue extends StepperValue = StepperValue> =
React.ComponentPropsWithoutRef<"div"> & {
value: TValue;
forceMount?: boolean;
keepMounted?: boolean;
asChild?: boolean;
};
type StepperButtonProps = React.ComponentPropsWithoutRef<"button"> & {
asChild?: boolean;
};
type StepperPreviousProps = StepperButtonProps & {
onBeforePrevious?: StepperNavigationGuard;
};
type StepperNextProps = StepperButtonProps & {
onBeforeNext?: StepperNavigationGuard;
};
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
const isDevelopment = process.env.NODE_ENV !== "production";
function warnDev(message: string) {
if (isDevelopment) {
console.warn(message);
}
}
function getSafeId(value: StepperValue) {
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
}
function normalizeStep<TValue extends StepperValue>(
step: StepperStepInput<TValue>
): StepperStep<TValue> {
return {
value: step.value,
disabled: Boolean(step.disabled),
};
}
function normalizeSteps<TValue extends StepperValue>(
steps: readonly StepperStepInput<TValue>[] | undefined
) {
return steps?.map(normalizeStep) ?? [];
}
function getDuplicateStepValues<TValue extends StepperValue>(
steps: ReadonlyArray<{ value: TValue }>
) {
const seenValues = new Set<TValue>();
const duplicateValues = new Set<TValue>();
steps.forEach((step) => {
if (seenValues.has(step.value)) {
duplicateValues.add(step.value);
return;
}
seenValues.add(step.value);
});
return Array.from(duplicateValues);
}
function getStepByValue<TValue extends StepperValue>(
steps: readonly StepperStep<TValue>[],
value: TValue | undefined
) {
return steps.find((step) => step.value === value);
}
function getCurrentStepValue<TValue extends StepperValue>(
steps: readonly StepperStep<TValue>[],
selectedValue: TValue | undefined
) {
const selectedStep = getStepByValue(steps, selectedValue);
if (selectedStep && !selectedStep.disabled) {
return selectedStep.value;
}
return steps.find((step) => !step.disabled)?.value;
}
function getNextEnabledStep<TValue extends StepperValue>(
steps: readonly StepperStep<TValue>[],
currentValue: TValue | undefined
) {
const currentIndex = steps.findIndex((step) => step.value === currentValue);
if (currentIndex === -1) {
return undefined;
}
return steps.slice(currentIndex + 1).find((step) => !step.disabled);
}
function getPreviousEnabledStep<TValue extends StepperValue>(
steps: readonly StepperStep<TValue>[],
currentValue: TValue | undefined
) {
const currentIndex = steps.findIndex((step) => step.value === currentValue);
if (currentIndex === -1) {
return undefined;
}
return steps
.slice(0, currentIndex)
.reverse()
.find((step) => !step.disabled);
}
function getKeyboardNavigationStepValue<TValue extends StepperValue>({
key,
orientation,
steps,
value,
}: {
key: string;
orientation: StepperOrientation;
steps: readonly StepperStep<TValue>[];
value: TValue;
}) {
const enabledSteps = steps.filter((step) => !step.disabled);
const currentIndex = enabledSteps.findIndex((step) => step.value === value);
if (currentIndex < 0) {
return undefined;
}
if (key === "Home") {
return enabledSteps.at(0)?.value;
}
if (key === "End") {
return enabledSteps.at(-1)?.value;
}
if (
(orientation === "horizontal" && key === "ArrowRight") ||
(orientation === "vertical" && key === "ArrowDown")
) {
return enabledSteps[Math.min(currentIndex + 1, enabledSteps.length - 1)]
?.value;
}
if (
(orientation === "horizontal" && key === "ArrowLeft") ||
(orientation === "vertical" && key === "ArrowUp")
) {
return enabledSteps[Math.max(currentIndex - 1, 0)]?.value;
}
return undefined;
}
async function resolveNavigationGuard(
guard: StepperNavigationGuard | undefined
) {
if (!guard) {
return true;
}
return (await guard()) !== false;
}
function setDisplayName(component: object, displayName: string) {
(component as { displayName?: string }).displayName = displayName;
}
// ----------------------------------------------------------------------------
// Context
// ----------------------------------------------------------------------------
const StepperContext = React.createContext<StepperContextValue | null>(null);
const StepperItemContext =
React.createContext<StepperItemContextValue | null>(null);
const StepperListContext = React.createContext(false);
function useStepperContext(component: string) {
const context = React.useContext(StepperContext);
if (!context) {
throw new Error(`${component} must be used within Stepper`);
}
return context;
}
function useStepperItemContext(component: string) {
const context = React.useContext(StepperItemContext);
if (!context) {
throw new Error(`${component} must be used within StepperItem`);
}
return context;
}
function useStepperListContext() {
return React.useContext(StepperListContext);
}
// ----------------------------------------------------------------------------
// Internal Hooks
// ----------------------------------------------------------------------------
function useRegisteredSteps() {
const [registeredSteps, setRegisteredSteps] = React.useState<
RegisteredStep[]
>([]);
const registerStep = React.useCallback((step: RegisteredStep) => {
setRegisteredSteps((currentSteps) => {
const existingStepIndex = currentSteps.findIndex(
(currentStep) => currentStep.id === step.id
);
if (existingStepIndex === -1) {
return [...currentSteps, step];
}
const existingStep = currentSteps[existingStepIndex];
if (
existingStep.value === step.value &&
existingStep.disabled === step.disabled
) {
return currentSteps;
}
const nextSteps = [...currentSteps];
nextSteps[existingStepIndex] = step;
return nextSteps;
});
}, []);
const unregisterStep = React.useCallback((stepId: string) => {
setRegisteredSteps((currentSteps) =>
currentSteps.filter((step) => step.id !== stepId)
);
}, []);
return {
registeredSteps,
registerStep,
unregisterStep,
};
}
function useStepperSteps(
stepsProp: readonly StepperStepInput[] | undefined,
registeredSteps: RegisteredStep[]
) {
const isExplicitMode = stepsProp !== undefined;
const explicitSteps = React.useMemo(
() => (isExplicitMode ? normalizeSteps(stepsProp) : undefined),
[isExplicitMode, stepsProp]
);
const steps = React.useMemo(
() =>
explicitSteps ??
registeredSteps.map((step) => ({
value: step.value,
disabled: step.disabled,
})),
[explicitSteps, registeredSteps]
);
return {
isExplicitMode,
steps,
};
}
function useStepperValue({
value,
defaultValue,
onValueChange,
steps,
}: {
value: StepperValue | undefined;
defaultValue: StepperValue | undefined;
onValueChange: ((value: StepperValue) => void) | undefined;
steps: StepperStep[];
}) {
const isControlled = value !== undefined;
const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);
const selectedValue = isControlled ? value : uncontrolledValue;
const currentValue = React.useMemo(
() => getCurrentStepValue(steps, selectedValue),
[selectedValue, steps]
);
const currentIndex = React.useMemo(
() =>
currentValue === undefined
? -1
: steps.findIndex((step) => step.value === currentValue),
[currentValue, steps]
);
const setValue = React.useCallback(
(nextValue: StepperValue) => {
const nextStepRecord = getStepByValue(steps, nextValue);
if (!nextStepRecord || nextStepRecord.disabled) {
return;
}
if (!isControlled) {
setUncontrolledValue(nextValue);
}
onValueChange?.(nextValue);
},
[isControlled, onValueChange, steps]
);
return {
currentValue,
currentIndex,
setValue,
};
}
function useStepperNavigation({
currentValue,
setValue,
steps,
}: {
currentValue: StepperValue | undefined;
setValue: (value: StepperValue) => void;
steps: StepperStep[];
}) {
const previousStep = React.useMemo(
() => getPreviousEnabledStep(steps, currentValue),
[currentValue, steps]
);
const nextStep = React.useMemo(
() => getNextEnabledStep(steps, currentValue),
[currentValue, steps]
);
const goPrevious = React.useCallback(() => {
if (previousStep) {
setValue(previousStep.value);
}
}, [previousStep, setValue]);
const goNext = React.useCallback(() => {
if (nextStep) {
setValue(nextStep.value);
}
}, [nextStep, setValue]);
return {
canGoPrevious: Boolean(previousStep),
canGoNext: Boolean(nextStep),
goPrevious,
goNext,
};
}
function useStepperTransition(currentIndex: number) {
const previousIndexRef = React.useRef(currentIndex);
const [transition, setTransition] = React.useState(() => ({
from: currentIndex,
to: currentIndex,
}));
React.useLayoutEffect(() => {
const previousIndex = previousIndexRef.current;
if (previousIndex === currentIndex) {
return;
}
setTransition({
from: previousIndex,
to: currentIndex,
});
previousIndexRef.current = currentIndex;
}, [currentIndex]);
return transition;
}
function getStepTransitionDelay({
from,
index,
to,
}: {
from: number;
index: number;
to: number;
}) {
if (from < 0 || to < 0 || index < 0 || from === to) {
return 0;
}
if (to > from) {
return index >= from && index < to ? (index - from) * 80 : 0;
}
return index >= to && index < from ? (from - index - 1) * 80 : 0;
}
function useDuplicateStepWarning(steps: StepperStep[]) {
const duplicateStepValues = React.useMemo(
() => getDuplicateStepValues(steps),
[steps]
);
const duplicateStepValuesKey = duplicateStepValues.join("\0");
React.useEffect(() => {
if (!isDevelopment || duplicateStepValues.length === 0) {
return;
}
duplicateStepValues.forEach((stepValue) => {
warnDev(
`StepperItem value "${stepValue}" is duplicated. Step values must be unique within a Stepper.`
);
});
}, [duplicateStepValues, duplicateStepValuesKey]);
}
function useNavigationButton({
canNavigate,
disabled,
navigate,
onBeforeNavigate,
}: {
canNavigate: boolean;
disabled: boolean | undefined;
navigate: () => void;
onBeforeNavigate: StepperNavigationGuard | undefined;
}) {
const [isPending, setIsPending] = React.useState(false);
const pendingRef = React.useRef(false);
const isDisabled = disabled || !canNavigate || isPending;
const handleClick = React.useCallback(
async (
event: React.MouseEvent<HTMLButtonElement>,
onClick: React.MouseEventHandler<HTMLButtonElement> | undefined
) => {
onClick?.(event);
if (isDisabled || pendingRef.current) {
event.preventDefault();
return;
}
if (event.defaultPrevented) {
return;
}
pendingRef.current = true;
setIsPending(true);
try {
const canCompleteNavigation =
await resolveNavigationGuard(onBeforeNavigate);
if (canCompleteNavigation) {
navigate();
}
} finally {
pendingRef.current = false;
setIsPending(false);
}
},
[isDisabled, navigate, onBeforeNavigate]
);
return {
isDisabled,
handleClick,
};
}
function useStepper<
TValue extends StepperValue = StepperValue,
>(): StepperApi<TValue> {
const context = useStepperContext(
"useStepper"
) as unknown as StepperContextValue<TValue>;
return {
value: context.value,
orientation: context.orientation,
steps: context.steps,
currentIndex: context.currentIndex,
totalSteps: context.totalSteps,
setValue: context.setValue,
getStepIndex: context.getStepIndex,
canGoPrevious: context.canGoPrevious,
canGoNext: context.canGoNext,
goPrevious: context.goPrevious,
goNext: context.goNext,
};
}
function useStepperItem<
TValue extends StepperValue = StepperValue,
>(): StepperItemApi<TValue> {
return useStepperItemContext(
"useStepperItem"
) as unknown as StepperItemContextValue<TValue>;
}
// ----------------------------------------------------------------------------
// Root
// ----------------------------------------------------------------------------
function Stepper<const TSteps extends readonly StepperStepInput[]>(
props: StepperPropsWithSteps<TSteps>
): React.ReactElement;
function Stepper<TValue extends StepperValue = StepperValue>(
props: StepperPropsWithoutSteps<TValue>
): React.ReactElement;
function Stepper({
value,
defaultValue,
onValueChange,
orientation = "horizontal",
steps: stepsProp,
className,
children,
...props
}: StepperProps) {
const id = React.useId();
const { registeredSteps, registerStep, unregisterStep } =
useRegisteredSteps();
const { isExplicitMode, steps } = useStepperSteps(
stepsProp,
registeredSteps
);
const {
currentValue,
currentIndex,
setValue: setStepperValue,
} = useStepperValue({
value,
defaultValue,
onValueChange,
steps,
});
const { canGoPrevious, canGoNext, goPrevious, goNext } =
useStepperNavigation({
currentValue,
setValue: setStepperValue,
steps,
});
const { from: transitionFromIndex, to: transitionToIndex } =
useStepperTransition(currentIndex);
useDuplicateStepWarning(steps);
const context = React.useMemo<StepperContextValue>(
() => ({
value: currentValue,
orientation,
steps,
isExplicitMode,
currentIndex,
transitionFromIndex,
transitionToIndex,
totalSteps: steps.length,
registerStep,
unregisterStep,
setValue: setStepperValue,
getStepIndex: (stepValue) =>
steps.findIndex((step) => step.value === stepValue),
getTriggerId: (stepValue) => `${id}-trigger-${getSafeId(stepValue)}`,
getContentId: (stepValue) => `${id}-content-${getSafeId(stepValue)}`,
canGoPrevious,
canGoNext,
goPrevious,
goNext,
}),
[
canGoNext,
canGoPrevious,
currentValue,
currentIndex,
transitionFromIndex,
transitionToIndex,
goNext,
goPrevious,
id,
isExplicitMode,
orientation,
registerStep,
setStepperValue,
steps,
unregisterStep,
]
);
return (
<StepperContext.Provider value={context}>
<div
data-slot="stepper"
data-orientation={orientation}
className={cn("flex flex-col gap-6", className)}
{...props}
>
{children}
</div>
</StepperContext.Provider>
);
}
// ----------------------------------------------------------------------------
// List
// ----------------------------------------------------------------------------
function StepperList({
className,
"aria-label": ariaLabel = "Progress steps",
children,
...props
}: StepperListProps) {
const { orientation } = useStepperContext("StepperList");
return (
<StepperListContext.Provider value={true}>
<ol
aria-label={props["aria-labelledby"] ? undefined : ariaLabel}
data-slot="stepper-list"
data-orientation={orientation}
className={cn(
"flex min-w-0",
"data-[orientation=horizontal]:w-full data-[orientation=horizontal]:gap-0 data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:pb-2",
"data-[orientation=vertical]:flex-col data-[orientation=vertical]:gap-4",
"[&>li:last-child_[data-slot=stepper-separator]]:hidden",
className
)}
{...props}
>
{children}
</ol>
</StepperListContext.Provider>
);
}
// ----------------------------------------------------------------------------
// Item
// ----------------------------------------------------------------------------
function StepperItem<TValue extends StepperValue = StepperValue>({
value,
completed = false,
defaultTrigger = true,
disabled = false,
error = false,
separator = true,
className,
style,
children,
...props
}: StepperItemProps<TValue>) {
const {
value: currentValue,
orientation,
steps,
isExplicitMode,
transitionFromIndex,
transitionToIndex,
registerStep,
unregisterStep,
setValue,
getStepIndex,
getTriggerId,
getContentId,
} = useStepperContext("StepperItem");
const registrationId = React.useId();
const isInsideStepperList = useStepperListContext();
const index = getStepIndex(value);
const step = getStepByValue(steps, value);
const isDisabled = step?.disabled ?? disabled;
const currentIndex =
currentValue === undefined ? -1 : getStepIndex(currentValue);
const isActive = currentValue === value;
const stepPosition: StepperStepPosition =
currentIndex < 0 || index < 0
? "next"
: index < currentIndex
? "previous"
: index === currentIndex
? "current"
: "next";
const stepState: StepperStepState = isDisabled
? "disabled"
: error
? "error"
: isActive
? "active"
: completed
? "completed"
: "inactive";
const renderDefaultTrigger = defaultTrigger;
const isLastStep = index >= 0 && index === steps.length - 1;
const shouldRenderSeparator =
isInsideStepperList && separator && !isLastStep;
const separatorTransitionDelay = getStepTransitionDelay({
from: transitionFromIndex,
index,
to: transitionToIndex,
});
const itemStyle = {
...style,
"--stepper-separator-delay": `${separatorTransitionDelay}ms`,
} as React.CSSProperties;
React.useLayoutEffect(() => {
if (!isInsideStepperList || isExplicitMode) {
return;
}
registerStep({
id: registrationId,
value,
disabled,
});
return () => unregisterStep(registrationId);
}, [
disabled,
isExplicitMode,
isInsideStepperList,
registerStep,
registrationId,
unregisterStep,
value,
]);
React.useEffect(() => {
if (!isDevelopment || isInsideStepperList) {
return;
}
const timeout = window.setTimeout(() => {
warnDev(
`StepperItem with value "${value}" must be rendered inside StepperList to participate in step order.`
);
}, 0);
return () => window.clearTimeout(timeout);
}, [isInsideStepperList, value]);
React.useEffect(() => {
if (
!isDevelopment ||
!isInsideStepperList ||
index >= 0
) {
return;
}
const timeout = window.setTimeout(() => {
warnDev(
`StepperItem with value "${value}" could not be found in the Stepper order. Check that it is rendered inside StepperList. For dynamic or conditional step order, pass an explicit steps prop to Stepper.`
);
}, 0);
return () => window.clearTimeout(timeout);
}, [index, isInsideStepperList, value]);
const itemContext = React.useMemo<StepperItemContextValue>(
() => ({
value,
index,
disabled: isDisabled,
completed,
isActive,
stepState,
stepPosition,
orientation,
setValue,
triggerId: getTriggerId(value),
contentId: getContentId(value),
}),
[
value,
index,
isDisabled,
completed,
isActive,
stepState,
stepPosition,
orientation,
setValue,
getTriggerId,
getContentId,
]
);
return (
<li
data-slot="stepper-item"
data-orientation={orientation}
data-state={stepState}
data-position={stepPosition}
data-disabled={isDisabled ? "" : undefined}
data-error={error ? "" : undefined}
data-completed={completed ? "" : undefined}
className={cn(
"group/stepper-item relative flex min-w-0",
"[--stepper-indicator-size:1.75rem] [--stepper-separator-offset:calc(var(--stepper-indicator-size)_/_2)] [--stepper-separator-y:calc(var(--stepper-indicator-size)_/_2)] sm:[--stepper-indicator-size:2.25rem]",
"data-[orientation=horizontal]:min-w-16 data-[orientation=horizontal]:flex-1 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:items-center sm:data-[orientation=horizontal]:min-w-28",
"data-[orientation=vertical]:items-start data-[orientation=vertical]:gap-3",
className
)}
style={itemStyle}
{...props}
>
<StepperItemContext.Provider value={itemContext}>
{renderDefaultTrigger ? (
<>
<StepperTrigger>
<StepperIndicator />
<StepperLabel>{children}</StepperLabel>
</StepperTrigger>
{shouldRenderSeparator ? <StepperSeparator /> : null}
</>
) : (
<>
{children}
{shouldRenderSeparator ? <StepperSeparator /> : null}
</>
)}
</StepperItemContext.Provider>
</li>
);
}
// ----------------------------------------------------------------------------
// Trigger
// ----------------------------------------------------------------------------
function StepperTrigger({
asChild = false,
className,
children,
disabled,
onClick,
onKeyDown,
tabIndex,
...props
}: StepperTriggerProps) {
const {
value,
disabled: itemDisabled,
isActive,
stepState,
orientation,
setValue,
triggerId,
contentId,
} = useStepperItemContext("StepperTrigger");
const { steps, getTriggerId } = useStepperContext("StepperTrigger");
const isDisabled = itemDisabled || disabled;
const Comp = asChild ? Slot : "button";
return (
<Comp
id={triggerId}
type={asChild ? undefined : "button"}
aria-current={isActive ? "step" : undefined}
aria-controls={isActive ? contentId : undefined}
aria-disabled={isDisabled ? true : undefined}
disabled={asChild ? undefined : isDisabled}
tabIndex={asChild && isDisabled ? -1 : tabIndex}
data-slot="stepper-trigger"
data-state={stepState}
data-disabled={isDisabled ? "" : undefined}
className={cn(
"group inline-flex min-h-10 min-w-0 items-center gap-2 rounded-lg text-left text-sm font-medium outline-none",
"text-muted-foreground transition-[color,background-color,border-color,box-shadow,transform] hover:text-foreground active:scale-[0.96]",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"data-[state=active]:text-foreground",
"data-[state=completed]:text-foreground",
"data-[state=error]:text-destructive",
orientation === "horizontal" && "w-full flex-col items-center text-center",
orientation === "vertical" && "justify-start",
className
)}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (isDisabled) {
event.preventDefault();
return;
}
if (!event.defaultPrevented) {
setValue(value);
}
}}
onKeyDown={(event: React.KeyboardEvent<HTMLButtonElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || isDisabled) {
return;
}
const targetStepValue = getKeyboardNavigationStepValue({
key: event.key,
orientation,
steps,
value,
});
if (!targetStepValue || targetStepValue === value) {
return;
}
const targetTrigger = document.getElementById(
getTriggerId(targetStepValue)
);
if (targetTrigger instanceof HTMLElement) {
event.preventDefault();
targetTrigger.focus();
}
}}
{...props}
>
{children}
</Comp>
);
}
function StepperIndicator({
className,
children,
...props
}: StepperIndicatorProps) {
const { index, stepState } = useStepperItemContext("StepperIndicator");
const stepNumber = index >= 0 ? index + 1 : undefined;
const content =
children !== undefined
? children
: stepState === "error"
? "!"
: stepState === "completed"
? "\u2713"
: stepNumber
? stepNumber
: null;
return (
<>
{stepNumber ? (
<span className="sr-only">Step {stepNumber}:</span>
) : null}
<span
aria-hidden="true"
data-slot="stepper-indicator"
className={cn(
"flex size-(--stepper-indicator-size) shrink-0 items-center justify-center rounded-full border border-border bg-background text-xs font-semibold text-muted-foreground shadow-sm",
"transition-[color,background-color,border-color,box-shadow,transform]",
"group-hover:border-foreground/30",
"group-data-[state=active]:border-primary group-data-[state=active]:bg-primary group-data-[state=active]:text-primary-foreground group-data-[state=active]:shadow-md",
"group-data-[state=completed]:border-primary group-data-[state=completed]:bg-primary group-data-[state=completed]:text-primary-foreground",
"group-data-[state=error]:border-destructive group-data-[state=error]:bg-destructive group-data-[state=error]:text-destructive-foreground",
"group-data-[position=previous]:text-foreground",
"[transition-delay:var(--stepper-separator-delay)] motion-reduce:[transition-delay:0ms]",
"[&>svg]:size-3.5 [&>svg]:shrink-0 sm:[&>svg]:size-4",
className
)}
{...props}
>
{content}
</span>
</>
);
}
function StepperLabel({ className, ...props }: StepperLabelProps) {
const { orientation } = useStepperItemContext("StepperLabel");
return (
<span
data-slot="stepper-label"
className={cn(
"min-w-0 text-xs leading-tight font-medium sm:text-sm",
orientation === "horizontal" && "max-w-40 text-balance",
className
)}
{...props}
/>
);
}
function StepperDescription({ className, ...props }: StepperDescriptionProps) {
const { orientation } = useStepperItemContext("StepperDescription");
return (
<span
data-slot="stepper-description"
className={cn(
"text-xs leading-snug font-normal text-muted-foreground",
orientation === "horizontal" && "max-w-44 text-balance",
className
)}
{...props}
/>
);
}
function StepperSeparator({ className, ...props }: StepperSeparatorProps) {
const { orientation } = useStepperItemContext("StepperSeparator");
return (
<span
aria-hidden="true"
data-slot="stepper-separator"
className={cn(
"overflow-hidden bg-muted-foreground/25 after:absolute after:inset-0 after:bg-primary after:content-['']",
"after:transition-transform after:duration-[220ms] after:ease-out after:[transition-delay:var(--stepper-separator-delay)] motion-reduce:after:transition-none motion-reduce:after:[transition-delay:0ms]",
orientation === "horizontal" &&
"absolute left-[calc(50%_+_var(--stepper-separator-offset))] right-[calc(-50%_+_var(--stepper-separator-offset))] top-(--stepper-separator-y) h-px after:origin-left after:scale-x-0 group-data-[completed]/stepper-item:after:scale-x-100 group-data-[position=previous]/stepper-item:after:scale-x-100 group-data-[state=completed]/stepper-item:after:scale-x-100",
orientation === "vertical" &&
"absolute left-[calc(var(--stepper-indicator-size)_/_2)] top-[calc(var(--stepper-indicator-size)_+_0.5rem)] h-[calc(100%_-_var(--stepper-indicator-size)_+_0.75rem)] w-px after:origin-top after:scale-y-0 group-data-[completed]/stepper-item:after:scale-y-100 group-data-[position=previous]/stepper-item:after:scale-y-100 group-data-[state=completed]/stepper-item:after:scale-y-100",
className
)}
{...props}
/>
);
}
// ----------------------------------------------------------------------------
// Content
// ----------------------------------------------------------------------------
function StepperContent<TValue extends StepperValue = StepperValue>({
value,
forceMount = false,
keepMounted = false,
asChild = false,
className,
children,
...props
}: StepperContentProps<TValue>) {
const {
value: currentValue,
steps,
getTriggerId,
getContentId,
} = useStepperContext("StepperContent");
const isActive = currentValue === value;
const hasMatchingStep = steps.some((step) => step.value === value);
const shouldKeepMounted = forceMount || keepMounted;
React.useEffect(() => {
if (!isDevelopment || hasMatchingStep) {
return;
}
const timeout = window.setTimeout(() => {
warnDev(
`StepperContent value "${value}" does not match any StepperItem. Content values should map to a step value.`
);
}, 0);
return () => window.clearTimeout(timeout);
}, [hasMatchingStep, value]);
if (!shouldKeepMounted && !isActive) {
return null;
}
const Comp = asChild ? Slot : "div";
return (
<Comp
id={getContentId(value)}
role="region"
aria-labelledby={getTriggerId(value)}
data-slot="stepper-content"
data-state={isActive ? "active" : "inactive"}
hidden={shouldKeepMounted ? !isActive : undefined}
className={cn(
"rounded-lg border border-border/70 bg-muted/25 p-4 text-sm text-foreground shadow-sm",
className
)}
{...props}
>
{children}
</Comp>
);
}
// ----------------------------------------------------------------------------
// Navigation
// ----------------------------------------------------------------------------
function StepperPrevious({
asChild = false,
className,
children = "Previous",
disabled,
onBeforePrevious,
onClick,
tabIndex,
...props
}: StepperPreviousProps) {
const { canGoPrevious, goPrevious } = useStepperContext("StepperPrevious");
const { handleClick, isDisabled } = useNavigationButton({
canNavigate: canGoPrevious,
disabled,
navigate: goPrevious,
onBeforeNavigate: onBeforePrevious,
});
const Comp = asChild ? Slot : "button";
return (
<Comp
type={asChild ? undefined : "button"}
disabled={asChild ? undefined : isDisabled}
aria-disabled={isDisabled ? true : undefined}
tabIndex={asChild && isDisabled ? -1 : tabIndex}
data-slot="stepper-previous"
data-disabled={isDisabled ? "" : undefined}
className={
asChild
? className
: cn(
"inline-flex h-9 min-w-24 items-center justify-center gap-2 rounded-none border border-border bg-background px-3 text-sm font-medium text-foreground",
"transition-[color,background-color,border-color,box-shadow,transform] hover:border-foreground/20 hover:bg-muted hover:text-foreground active:scale-[0.97]",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&>svg]:size-4 [&>svg]:shrink-0",
className
)
}
onClick={(event: React.MouseEvent<HTMLButtonElement>) =>
handleClick(event, onClick)
}
{...props}
>
{children}
</Comp>
);
}
function StepperNext({
asChild = false,
className,
children = "Next",
disabled,
onBeforeNext,
onClick,
tabIndex,
...props
}: StepperNextProps) {
const { canGoNext, goNext } = useStepperContext("StepperNext");
const { handleClick, isDisabled } = useNavigationButton({
canNavigate: canGoNext,
disabled,
navigate: goNext,
onBeforeNavigate: onBeforeNext,
});
const Comp = asChild ? Slot : "button";
return (
<Comp
type={asChild ? undefined : "button"}
disabled={asChild ? undefined : isDisabled}
aria-disabled={isDisabled ? true : undefined}
tabIndex={asChild && isDisabled ? -1 : tabIndex}
data-slot="stepper-next"
data-disabled={isDisabled ? "" : undefined}
className={
asChild
? className
: cn(
"inline-flex h-9 min-w-24 items-center justify-center gap-2 rounded-none border border-primary bg-primary px-3 text-sm font-medium text-primary-foreground",
"transition-[background-color,border-color,box-shadow,transform] hover:bg-primary/90 active:scale-[0.97]",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&>svg]:size-4 [&>svg]:shrink-0",
className
)
}
onClick={(event: React.MouseEvent<HTMLButtonElement>) =>
handleClick(event, onClick)
}
{...props}
>
{children}
</Comp>
);
}
// ----------------------------------------------------------------------------
// Exports
// ----------------------------------------------------------------------------
setDisplayName(Stepper, "Stepper");
setDisplayName(StepperList, "StepperList");
setDisplayName(StepperItem, "StepperItem");
setDisplayName(StepperTrigger, "StepperTrigger");
setDisplayName(StepperIndicator, "StepperIndicator");
setDisplayName(StepperLabel, "StepperLabel");
setDisplayName(StepperDescription, "StepperDescription");
setDisplayName(StepperSeparator, "StepperSeparator");
setDisplayName(StepperContent, "StepperContent");
setDisplayName(StepperPrevious, "StepperPrevious");
setDisplayName(StepperNext, "StepperNext");
export {
Stepper,
StepperContent,
StepperDescription,
StepperIndicator,
StepperItem,
StepperLabel,
StepperList,
StepperNext,
StepperPrevious,
StepperSeparator,
StepperTrigger,
useStepper,
useStepperItem,
};
export type {
StepperApi,
StepperButtonProps,
StepperContentProps,
StepperDescriptionProps,
StepperIndicatorProps,
StepperItemApi,
StepperItemProps,
StepperLabelProps,
StepperListProps,
StepperNavigationGuard,
StepperNextProps,
StepperOrientation,
StepperPreviousProps,
StepperProps,
StepperSeparatorProps,
StepperStep,
StepperStepInput,
StepperStepPosition,
StepperStepState,
StepperStepsValue,
StepperTriggerProps,
StepperValue,
};
registry/default/examples/stepper-intent-onboarding.tsx
"use client";
import * as React from "react";
import {
ArrowRight,
BookOpen,
BriefcaseBusiness,
Check,
Circle,
CircleHelp,
Home,
ImagePlus,
KeyRound,
Laptop,
ListChecks,
LoaderCircle,
Mail,
Monitor,
Plane,
School,
Sparkles,
SquareCheckBig,
UsersRound,
type LucideIcon,
} from "lucide-react";
import {
useForm,
useWatch,
type FieldPath,
type UseFormRegisterReturn,
} from "react-hook-form";
import { SiGoogle } from "react-icons/si";
import { z } from "zod/v3";
import { StepperLogo } from "@/components/stepper-logo";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Stepper,
StepperContent,
StepperItem,
StepperList,
} from "@/components/ui/stepper";
import { cn } from "@/lib/utils";
const emailStepSchema = z.object({
email: z.string().trim().email("Enter a valid work email."),
});
const verificationStepSchema = z.object({
code: z
.string()
.trim()
.regex(/^\d{6}$/, "Enter the 6-digit verification code."),
});
const profileStepSchema = z.object({
name: z.string().trim().min(2, "Enter your name."),
password: z.string().min(8, "Use at least 8 characters."),
});
const intentStepSchema = z.object({
intent: z.enum(["work", "personal", "school"], {
required_error: "Choose what you are setting up.",
}),
});
const collaborationStepSchema = z.object({
collaboration: z.enum(["with-others", "on-my-own"], {
required_error: "Choose a collaboration mode.",
}),
});
const onboardingStepValues = [
"account",
"verify",
"profile",
"intent",
"collaboration",
"interests",
"generating",
"workspace",
] as const;
type OnboardingStep = (typeof onboardingStepValues)[number];
type IntentValue = z.infer<typeof intentStepSchema>["intent"];
type CollaborationValue = z.infer<
typeof collaborationStepSchema
>["collaboration"];
type InterestValue =
| "career"
| "books"
| "todo"
| "site"
| "travel"
| "projects"
| "finance"
| "food"
| "hobbies"
| "habits";
type IntentOnboardingValues = {
email: string;
code: string;
name: string;
password: string;
intent?: IntentValue;
collaboration?: CollaborationValue;
interests: InterestValue[];
marketingOptOut: boolean;
};
type StepDefinition = {
value: OnboardingStep;
label: string;
};
const onboardingSteps = [
{ value: "account", label: "Create account" },
{ value: "verify", label: "Verify email" },
{ value: "profile", label: "Create profile" },
{ value: "intent", label: "Choose intent" },
{ value: "collaboration", label: "Choose collaboration" },
{ value: "interests", label: "Choose interests" },
{ value: "generating", label: "Prepare setup" },
{ value: "workspace", label: "Signed in" },
] satisfies StepDefinition[];
const intentOptions = [
{
value: "work",
title: "For work",
description: "Track projects, goals, and meeting notes",
icon: BriefcaseBusiness,
},
{
value: "personal",
title: "For personal life",
description: "Write better, think more clearly, stay organized",
icon: Home,
},
{
value: "school",
title: "For school",
description: "Keep notes, research, and tasks in one place",
icon: School,
},
] satisfies Array<{
value: IntentValue;
title: string;
description: string;
icon: LucideIcon;
}>;
const collaborationOptions = [
{
value: "with-others",
title: "With others",
description: "Plan travel, household chores, group activities",
icon: UsersRound,
},
{
value: "on-my-own",
title: "On my own",
description: "Manage expenses, track hobbies, stay organized",
icon: CircleHelp,
},
] satisfies Array<{
value: CollaborationValue;
title: string;
description: string;
icon: LucideIcon;
}>;
const interestOptions = [
{ value: "career", label: "Career building", icon: BriefcaseBusiness },
{ value: "books", label: "Books and media", icon: Monitor },
{ value: "todo", label: "To-do list", icon: ListChecks },
{ value: "site", label: "Site or blog", icon: Laptop },
{ value: "travel", label: "Travel", icon: Plane },
{ value: "projects", label: "Project tracking", icon: SquareCheckBig },
{ value: "finance", label: "Personal finance", icon: Circle },
{ value: "food", label: "Food and nutrition", icon: Home },
{ value: "hobbies", label: "Hobbies", icon: BookOpen },
{ value: "habits", label: "Habit tracking", icon: Check },
] satisfies Array<{
value: InterestValue;
label: string;
icon: LucideIcon;
}>;
const stepFields = {
account: ["email"],
verify: ["code"],
profile: ["name", "password"],
intent: ["intent"],
collaboration: ["collaboration"],
interests: [],
generating: [],
workspace: [],
} satisfies Record<OnboardingStep, FieldPath<IntentOnboardingValues>[]>;
function StepperIntentOnboardingExample() {
const [step, setStep] = React.useState<OnboardingStep>("account");
const [completedSteps, setCompletedSteps] = React.useState<
Partial<Record<OnboardingStep, boolean>>
>({});
const [isGenerating, setIsGenerating] = React.useState(false);
const generationTimeoutRef = React.useRef<number | null>(null);
const form = useForm<IntentOnboardingValues>({
mode: "onChange",
defaultValues: {
email: "",
code: "",
name: "",
password: "",
intent: undefined,
collaboration: undefined,
interests: [],
marketingOptOut: false,
},
});
const watchedValues = useWatch({ control: form.control });
const values = React.useMemo<IntentOnboardingValues>(
() => ({
email: watchedValues.email ?? "",
code: watchedValues.code ?? "",
name: watchedValues.name ?? "",
password: watchedValues.password ?? "",
intent: watchedValues.intent,
collaboration: watchedValues.collaboration,
interests: watchedValues.interests ?? [],
marketingOptOut: watchedValues.marketingOptOut ?? false,
}),
[
watchedValues.code,
watchedValues.collaboration,
watchedValues.email,
watchedValues.intent,
watchedValues.interests,
watchedValues.marketingOptOut,
watchedValues.name,
watchedValues.password,
]
);
const currentIndex = getVisibleStepIndex(step, values);
const totalVisibleSteps = getVisibleSteps(values).length;
const progress = Math.round(((currentIndex + 1) / totalVisibleSteps) * 100);
React.useEffect(() => {
return () => {
if (generationTimeoutRef.current) {
window.clearTimeout(generationTimeoutRef.current);
}
};
}, []);
async function continueFromStep() {
const isValid = validateCurrentStep(step, form);
if (!isValid) {
return;
}
setCompletedSteps((current) => ({ ...current, [step]: true }));
if (step === "interests") {
startGeneration();
return;
}
const nextStep = getNextStep(step, values);
if (nextStep) {
setStep(nextStep);
}
}
function continueWithProvider(provider: string) {
const providerEmail =
provider === "Google" ? "alex.smith@example.com" : "alex@example.com";
form.clearErrors(["email", "code"]);
form.setValue("email", providerEmail, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
setCompletedSteps((current) => ({
...current,
account: true,
verify: true,
}));
setStep("profile");
}
function resetFlow() {
if (generationTimeoutRef.current) {
window.clearTimeout(generationTimeoutRef.current);
generationTimeoutRef.current = null;
}
setIsGenerating(false);
setCompletedSteps({});
form.reset();
setStep("account");
}
function selectIntent(intent: IntentValue) {
form.clearErrors("intent");
form.setValue("intent", intent, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
setCompletedSteps((current) => ({
...current,
intent: false,
collaboration: false,
}));
}
function selectCollaboration(collaboration: CollaborationValue) {
form.clearErrors("collaboration");
form.setValue("collaboration", collaboration, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
}
function toggleInterest(interest: InterestValue) {
const currentInterests = values.interests;
const nextInterests = currentInterests.includes(interest)
? currentInterests.filter((item) => item !== interest)
: [...currentInterests, interest];
form.setValue("interests", nextInterests, {
shouldDirty: true,
shouldTouch: true,
});
}
function skipInterests() {
form.setValue("interests", [], {
shouldDirty: true,
shouldTouch: true,
});
setCompletedSteps((current) => ({ ...current, interests: true }));
startGeneration();
}
function startGeneration() {
setIsGenerating(true);
setStep("generating");
if (generationTimeoutRef.current) {
window.clearTimeout(generationTimeoutRef.current);
}
generationTimeoutRef.current = window.setTimeout(() => {
setIsGenerating(false);
setCompletedSteps((current) => ({
...current,
interests: true,
generating: true,
workspace: true,
}));
setStep("workspace");
}, 700);
}
return (
<Stepper
value={step}
onValueChange={(nextStep) => setStep(nextStep as OnboardingStep)}
className="min-w-0 gap-0 overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-sm"
>
<div className="h-1 bg-muted">
<div
className="h-full bg-foreground transition-[width] duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex min-h-[560px] flex-col">
<HiddenStepList completedSteps={completedSteps} values={values} />
<StepperContent value="account" className="border-0 bg-transparent p-0 shadow-none">
<CenteredStage>
<AuthPanel
email={values.email}
error={form.formState.errors.email?.message}
registerEmail={form.register("email", {
onChange: () => form.clearErrors("email"),
})}
onContinue={() => void continueFromStep()}
onGoogle={() => continueWithProvider("Google")}
/>
</CenteredStage>
</StepperContent>
<StepperContent value="verify" className="border-0 bg-transparent p-0 shadow-none">
<CenteredStage>
<VerifyPanel
email={values.email}
error={form.formState.errors.code?.message}
registerCode={form.register("code", {
onChange: () => form.clearErrors("code"),
})}
onContinue={() => void continueFromStep()}
/>
</CenteredStage>
</StepperContent>
<StepperContent value="profile" className="border-0 bg-transparent p-0 shadow-none">
<CenteredStage>
<ProfilePanel
values={values}
errors={{
name: form.formState.errors.name?.message,
password: form.formState.errors.password?.message,
}}
registerName={form.register("name", {
onChange: () => form.clearErrors("name"),
})}
registerPassword={form.register("password", {
onChange: () => form.clearErrors("password"),
})}
marketingOptOut={values.marketingOptOut}
onMarketingChange={(checked) =>
form.setValue("marketingOptOut", checked, {
shouldDirty: true,
shouldTouch: true,
})
}
onContinue={() => void continueFromStep()}
/>
</CenteredStage>
</StepperContent>
<StepperContent value="intent" className="border-0 bg-transparent p-0 shadow-none">
<CenteredStage>
<IntentPanel
value={values.intent}
error={form.formState.errors.intent?.message}
onSelect={selectIntent}
onContinue={() => void continueFromStep()}
/>
</CenteredStage>
</StepperContent>
<StepperContent
value="collaboration"
className="border-0 bg-transparent p-0 shadow-none"
>
<CenteredStage>
<CollaborationPanel
value={values.collaboration}
error={form.formState.errors.collaboration?.message}
onSelect={selectCollaboration}
onContinue={() => void continueFromStep()}
/>
</CenteredStage>
</StepperContent>
<StepperContent value="interests" className="border-0 bg-transparent p-0 shadow-none">
<CenteredStage>
<InterestsPanel
selectedInterests={values.interests}
onToggleInterest={toggleInterest}
onContinue={() => void continueFromStep()}
onSkip={skipInterests}
/>
</CenteredStage>
</StepperContent>
<StepperContent value="generating" className="border-0 bg-transparent p-0 shadow-none">
<CenteredStage>
<GeneratingPanel values={values} isGenerating={isGenerating} />
</CenteredStage>
</StepperContent>
<StepperContent value="workspace" className="border-0 bg-transparent p-0 shadow-none">
<SignedInPanel values={values} onRestart={resetFlow} />
</StepperContent>
</div>
</Stepper>
);
}
function HiddenStepList({
completedSteps,
values,
}: {
completedSteps: Partial<Record<OnboardingStep, boolean>>;
values: IntentOnboardingValues;
}) {
return (
<StepperList
aria-label="Intent onboarding flow steps"
className="sr-only !w-px !gap-0 !overflow-hidden !pb-0 data-[orientation=horizontal]:!w-px data-[orientation=horizontal]:!gap-0 data-[orientation=horizontal]:!overflow-hidden data-[orientation=horizontal]:!pb-0"
>
{onboardingSteps.map((item) => (
<StepperItem
key={item.value}
value={item.value}
completed={Boolean(completedSteps[item.value])}
disabled={isStepSkipped(item.value, values)}
>
{item.label}
</StepperItem>
))}
</StepperList>
);
}
function CenteredStage({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-[560px] flex-1 items-start justify-center px-5 pb-10 pt-14 sm:px-6 sm:pt-16">
{children}
</div>
);
}
function AuthPanel({
email,
error,
registerEmail,
onContinue,
onGoogle,
}: {
email: string;
error?: string;
registerEmail: UseFormRegisterReturn<"email">;
onContinue: () => void;
onGoogle: () => void;
}) {
return (
<div className="w-full max-w-[380px]">
<StageHeading
title="Create your account"
description="Start with a focused onboarding flow."
align="center"
logo
/>
<ProviderButton onClick={onGoogle} />
<div className="my-5 flex items-center gap-3 text-xs font-medium text-muted-foreground">
<Separator className="flex-1 bg-border" />
or
<Separator className="flex-1 bg-border" />
</div>
<Field data-invalid={Boolean(error)}>
<FieldLabel htmlFor="intent-email" className="text-muted-foreground">
Work email
</FieldLabel>
<div className="relative">
<Input
id="intent-email"
type="email"
placeholder="Enter your email address..."
autoComplete="email"
aria-invalid={Boolean(error)}
className="h-10 rounded-md border-border bg-background pr-10 text-sm shadow-none focus-visible:ring-ring"
{...registerEmail}
/>
{email ? (
<span className="absolute top-1/2 right-3 -translate-y-1/2 rounded-full bg-foreground p-0.5 text-background">
<Check className="size-3" />
</span>
) : (
<Mail className="absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground" />
)}
</div>
{error ? (
<FieldError>{error}</FieldError>
) : (
<FieldDescription className="text-muted-foreground">
Use an organization email to easily collaborate with teammates.
</FieldDescription>
)}
</Field>
<PrimaryAction className="mt-6" onClick={onContinue}>
Continue
</PrimaryAction>
<p className="mt-5 text-center text-xs leading-5 text-muted-foreground">
By continuing, you acknowledge that you understand and agree to the{" "}
<span className="underline decoration-border underline-offset-2">
Terms & Conditions
</span>{" "}
and{" "}
<span className="underline decoration-border underline-offset-2">
Privacy Policy
</span>
.
</p>
</div>
);
}
function VerifyPanel({
email,
error,
registerCode,
onContinue,
}: {
email: string;
error?: string;
registerCode: UseFormRegisterReturn<"code">;
onContinue: () => void;
}) {
return (
<div className="w-full max-w-[380px]">
<StageHeading
title="Check your inbox"
description="Use the code sent to your email."
align="center"
/>
<div className="mt-7 rounded-lg border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
We sent a verification code to{" "}
<span className="font-medium text-foreground">{email}</span>.
</div>
<Field data-invalid={Boolean(error)} className="mt-5">
<FieldLabel htmlFor="intent-code" className="text-muted-foreground">
Verification code
</FieldLabel>
<Input
id="intent-code"
inputMode="numeric"
placeholder="Enter code"
aria-invalid={Boolean(error)}
className="h-10 rounded-md border-border bg-background font-mono text-sm shadow-none focus-visible:ring-ring"
{...registerCode}
/>
{error ? (
<FieldError>{error}</FieldError>
) : (
<FieldDescription className="text-muted-foreground">
Use 123456 for this demo.
</FieldDescription>
)}
</Field>
<PrimaryAction className="mt-6" onClick={onContinue}>
Continue
</PrimaryAction>
<p className="mt-4 text-center text-sm text-muted-foreground">
Resend in 25s
</p>
</div>
);
}
function ProfilePanel({
values,
errors,
registerName,
registerPassword,
marketingOptOut,
onMarketingChange,
onContinue,
}: {
values: IntentOnboardingValues;
errors: { name?: string; password?: string };
registerName: UseFormRegisterReturn<"name">;
registerPassword: UseFormRegisterReturn<"password">;
marketingOptOut: boolean;
onMarketingChange: (checked: boolean) => void;
onContinue: () => void;
}) {
return (
<div className="w-full max-w-[380px]">
<StageHeading
title="Create a profile"
description="This is how you'll appear."
align="center"
/>
<div className="mt-9 flex flex-col items-center gap-3 text-sm text-muted-foreground">
<div className="grid size-14 place-items-center rounded-full border border-border bg-muted/50 text-base font-semibold text-foreground">
{getInitials(values.name || values.email)}
</div>
<button
type="button"
className="inline-flex items-center gap-1 hover:text-foreground"
>
<ImagePlus className="size-4" />
Add a photo
</button>
</div>
<div className="mt-9 grid gap-4">
<Field data-invalid={Boolean(errors.name)}>
<FieldLabel htmlFor="intent-name" className="text-muted-foreground">
Enter your name
</FieldLabel>
<Input
id="intent-name"
placeholder="Name"
autoComplete="name"
aria-invalid={Boolean(errors.name)}
className="h-10 rounded-md border-border bg-background text-sm shadow-none focus-visible:ring-ring"
{...registerName}
/>
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.password)}>
<FieldLabel htmlFor="intent-password" className="text-muted-foreground">
Set a password
</FieldLabel>
<div className="relative">
<Input
id="intent-password"
type="password"
placeholder="Password"
autoComplete="new-password"
aria-invalid={Boolean(errors.password)}
className="h-10 rounded-md border-border bg-background pr-10 text-sm shadow-none focus-visible:ring-ring"
{...registerPassword}
/>
<KeyRound className="absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground" />
</div>
{errors.password ? (
<FieldError>{errors.password}</FieldError>
) : null}
</Field>
</div>
<PrimaryAction className="mt-9" onClick={onContinue}>
Continue
</PrimaryAction>
<label className="mt-4 flex items-start gap-2 text-xs leading-5 text-muted-foreground">
<Checkbox
checked={marketingOptOut}
onCheckedChange={(checked) => onMarketingChange(checked === true)}
className="mt-0.5 border-border"
/>
No, I do not want to receive marketing communications.
</label>
</div>
);
}
function IntentPanel({
value,
error,
onSelect,
onContinue,
}: {
value?: IntentValue;
error?: string;
onSelect: (value: IntentValue) => void;
onContinue: () => void;
}) {
return (
<div className="w-full max-w-[520px]">
<StageHeading
title="What are you setting up?"
description="This helps customize your experience"
align="center"
/>
<div className="mt-8 grid gap-3">
{intentOptions.map((option) => (
<LargeOptionButton
key={option.value}
active={value === option.value}
icon={option.icon}
title={option.title}
description={option.description}
onClick={() => onSelect(option.value)}
/>
))}
</div>
{error ? (
<p className="mt-3 text-center text-sm text-destructive">{error}</p>
) : null}
<PrimaryAction className="mt-7" onClick={onContinue}>
Continue
</PrimaryAction>
</div>
);
}
function CollaborationPanel({
value,
error,
onSelect,
onContinue,
}: {
value?: CollaborationValue;
error?: string;
onSelect: (value: CollaborationValue) => void;
onContinue: () => void;
}) {
return (
<div className="w-full max-w-[500px]">
<StageHeading
title="How will you work?"
description="Pick the mode that best fits this setup"
align="center"
/>
<div className="mt-8 grid gap-3 sm:grid-cols-2">
{collaborationOptions.map((option) => (
<TileOptionButton
key={option.value}
active={value === option.value}
icon={option.icon}
title={option.title}
description={option.description}
onClick={() => onSelect(option.value)}
/>
))}
</div>
{error ? (
<p className="mt-3 text-center text-sm text-destructive">{error}</p>
) : null}
<PrimaryAction className="mt-7" onClick={onContinue}>
Continue
</PrimaryAction>
</div>
);
}
function InterestsPanel({
selectedInterests,
onToggleInterest,
onContinue,
onSkip,
}: {
selectedInterests: InterestValue[];
onToggleInterest: (value: InterestValue) => void;
onContinue: () => void;
onSkip: () => void;
}) {
return (
<div className="w-full max-w-[520px] text-center">
<StageHeading
title="Choose starter goals"
description="Stepper will prepare the optional path after sign-in."
align="center"
/>
<p className="mb-3 mt-7 text-xs font-medium text-muted-foreground">
{selectedInterests.length} selected
</p>
<div className="flex flex-wrap justify-center gap-2.5">
{interestOptions.map((option) => (
<InterestChip
key={option.value}
option={option}
active={selectedInterests.includes(option.value)}
onClick={() => onToggleInterest(option.value)}
/>
))}
</div>
<PrimaryAction className="mx-auto mt-10 max-w-[380px]" onClick={onContinue}>
Continue
</PrimaryAction>
<button
type="button"
className="mx-auto mt-4 block w-full max-w-[380px] rounded-md py-1 text-sm font-medium text-muted-foreground hover:text-foreground"
onClick={onSkip}
>
Skip for now
</button>
</div>
);
}
function GeneratingPanel({
values,
isGenerating,
}: {
values: IntentOnboardingValues;
isGenerating: boolean;
}) {
const items = [
"Choosing starter pages",
"Mapping your interests",
"Preparing a completion checklist",
];
return (
<div className="w-full max-w-[460px] text-center">
<div className="mx-auto grid size-14 place-items-center rounded-xl border border-border bg-muted/40 shadow-sm">
<Sparkles className="size-6 text-foreground" />
</div>
<h2 className="mt-6 text-xl font-semibold tracking-tight text-foreground">
Preparing your setup
</h2>
<p className="mt-3 text-sm font-medium text-muted-foreground">
{values.intent
? `Tuned for ${getIntentLabel(values.intent).toLowerCase()}.`
: "Tuned for your workflow."}
</p>
<div className="mt-8 rounded-xl border border-border bg-muted/30 p-4 text-left">
{items.map((item, index) => (
<div key={item} className="flex items-center gap-3 py-2">
<span className="grid size-7 place-items-center rounded-full bg-background text-muted-foreground">
{index === items.length - 1 && isGenerating ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
</span>
<span className="text-sm font-medium text-foreground">{item}</span>
</div>
))}
</div>
</div>
);
}
function SignedInPanel({
values,
onRestart,
}: {
values: IntentOnboardingValues;
onRestart: () => void;
}) {
const selectedLabels = values.interests
.map(
(interest) =>
interestOptions.find((item) => item.value === interest)?.label
)
.filter(Boolean)
.slice(0, 2);
return (
<div className="flex min-h-[560px] flex-1 items-center justify-center px-5 py-12 sm:px-6">
<div className="w-full max-w-[360px] overflow-hidden rounded-xl border border-border bg-card text-left">
<div className="grid h-28 place-items-center border-b border-border bg-background">
<OnboardingLogo className="size-12 text-foreground" />
</div>
<div className="p-4 sm:p-5">
<h2 className="text-base font-semibold text-foreground">
You're signed in
</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Stepper completed the account, intent, and optional setup path
{values.email ? ` for ${values.email}` : ""}.
</p>
<div className="mt-5 grid gap-2 text-sm">
<ChecklistRow checked text="Account verified" />
<ChecklistRow checked text={getIntentSummary(values)} />
<ChecklistRow
checked
text={
selectedLabels.length > 0
? `Prepared ${selectedLabels.join(" and ")}`
: "Skipped optional goals"
}
/>
</div>
<PrimaryAction className="mt-6" onClick={onRestart}>
Run again
</PrimaryAction>
</div>
</div>
</div>
);
}
function StageHeading({
title,
description,
align = "left",
logo = false,
}: {
title: string;
description: string;
align?: "left" | "center";
logo?: boolean;
}) {
return (
<div className={cn(align === "center" && "text-center")}>
{logo ? <OnboardingLogo className="mx-auto mb-5 size-10" /> : null}
<h2 className="text-xl font-semibold tracking-tight text-foreground sm:text-2xl">
{title}
</h2>
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
{description}
</p>
</div>
);
}
function OnboardingLogo({ className }: { className?: string }) {
return <StepperLogo className={cn("size-10 text-foreground", className)} />;
}
function ProviderButton({ onClick }: { onClick: () => void }) {
return (
<Button
type="button"
variant="outline"
className="relative mt-7 h-9 w-full justify-center rounded-md border-border bg-background text-sm font-medium text-foreground shadow-none hover:bg-muted/60"
onClick={onClick}
>
<SiGoogle className="absolute left-4 size-4 text-foreground" aria-hidden="true" />
Continue with Google
</Button>
);
}
function PrimaryAction({
children,
className,
onClick,
}: {
children: React.ReactNode;
className?: string;
onClick: () => void;
}) {
return (
<Button
type="button"
className={cn(
"h-9 w-full rounded-md bg-foreground text-sm font-semibold text-background shadow-none hover:bg-foreground/90 focus-visible:ring-ring",
className
)}
onClick={onClick}
>
{children}
<ArrowRight data-icon="inline-end" />
</Button>
);
}
function LargeOptionButton({
active,
icon: Icon,
title,
description,
onClick,
}: {
active: boolean;
icon: LucideIcon;
title: string;
description: string;
onClick: () => void;
}) {
return (
<button
type="button"
className={cn(
"flex min-h-20 w-full items-center gap-4 rounded-lg border border-border bg-background px-5 text-left transition-colors hover:bg-muted/60",
active && "border-foreground bg-muted/30 ring-2 ring-foreground/10"
)}
onClick={onClick}
>
<Icon className="size-7 shrink-0 text-foreground" />
<span className="min-w-0">
<span className="block text-sm font-semibold text-foreground">
{title}
</span>
<span className="mt-1 block text-sm leading-5 text-muted-foreground">
{description}
</span>
</span>
</button>
);
}
function TileOptionButton({
active,
icon: Icon,
title,
description,
onClick,
}: {
active: boolean;
icon: LucideIcon;
title: string;
description: string;
onClick: () => void;
}) {
return (
<button
type="button"
className={cn(
"flex min-h-36 flex-col items-center justify-center rounded-lg border border-border bg-background p-5 text-center transition-colors hover:bg-muted/60",
active && "border-foreground bg-muted/30 ring-2 ring-foreground/10"
)}
onClick={onClick}
>
<Icon className="mb-4 size-8 text-foreground" />
<span className="text-sm font-semibold text-foreground">{title}</span>
<span className="mt-2 max-w-40 text-xs leading-5 text-muted-foreground">
{description}
</span>
</button>
);
}
function InterestChip({
option,
active,
onClick,
}: {
option: (typeof interestOptions)[number];
active: boolean;
onClick: () => void;
}) {
const Icon = option.icon;
return (
<button
type="button"
className={cn(
"inline-flex h-9 items-center gap-2 rounded-lg border border-border bg-background px-3.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted/60",
active && "border-foreground text-foreground ring-2 ring-foreground/10"
)}
onClick={onClick}
>
{active ? <Check className="size-4" /> : <Icon className="size-4" />}
{option.label}
</button>
);
}
function ChecklistRow({ checked, text }: { checked?: boolean; text: string }) {
return (
<div className="flex items-center gap-3">
<span
className={cn(
"grid size-5 shrink-0 place-items-center border border-border",
checked && "border-foreground bg-foreground text-background"
)}
>
{checked ? <Check className="size-4" /> : null}
</span>
<span className={cn(checked && "text-muted-foreground")}>
{text}
</span>
</div>
);
}
function validateCurrentStep(
step: OnboardingStep,
form: ReturnType<typeof useForm<IntentOnboardingValues>>
) {
const values = form.getValues();
const schema = getStepSchema(step);
form.clearErrors(stepFields[step]);
if (!schema) {
return true;
}
const result = schema.safeParse(values);
if (result.success) {
return true;
}
result.error.issues.forEach((issue) => {
const field = issue.path[0] as FieldPath<IntentOnboardingValues>;
form.setError(field, { message: issue.message, type: "manual" });
});
const firstIssue = result.error.issues[0];
if (firstIssue) {
const firstField = firstIssue.path[0] as FieldPath<IntentOnboardingValues>;
form.setFocus(firstField);
}
return false;
}
function getStepSchema(step: OnboardingStep) {
if (step === "account") return emailStepSchema;
if (step === "verify") return verificationStepSchema;
if (step === "profile") return profileStepSchema;
if (step === "intent") return intentStepSchema;
if (step === "collaboration") return collaborationStepSchema;
return undefined;
}
function getVisibleSteps(values: IntentOnboardingValues) {
return onboardingStepValues.filter((step) => !isStepSkipped(step, values));
}
function getVisibleStepIndex(step: OnboardingStep, values: IntentOnboardingValues) {
const visibleSteps = getVisibleSteps(values);
const index = visibleSteps.indexOf(step);
return index === -1 ? 0 : index;
}
function getNextStep(step: OnboardingStep, values: IntentOnboardingValues) {
const visibleSteps = getVisibleSteps(values);
const currentIndex = visibleSteps.indexOf(step);
return visibleSteps[currentIndex + 1];
}
function isStepSkipped(step: OnboardingStep, values: IntentOnboardingValues) {
return step === "collaboration" && values.intent !== "personal";
}
function getInitials(value: string) {
const normalized = value.trim();
if (!normalized) {
return "A";
}
return normalized
.split(/[\s@.]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("");
}
function getIntentLabel(intent: IntentValue) {
return intentOptions.find((option) => option.value === intent)?.title ?? intent;
}
function getIntentSummary(values: IntentOnboardingValues) {
if (!values.intent) {
return "Intent captured";
}
const label = getIntentLabel(values.intent).replace(/^For /, "");
return `Intent set for ${label.toLowerCase()}`;
}
export { StepperIntentOnboardingExample };
registry/default/components/stepper-logo.tsx
import type { SVGProps } from "react";
function StepperLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 789.79 1058.83"
fill="currentColor"
aria-hidden="true"
focusable="false"
{...props}
>
<path d="M361.13,1058.28c28.61-24.08,55.58-46.86,82.62-69.55,41.82-35.09,83.8-69.99,125.49-105.23,39.2-33.13,57.7-75.81,57.69-126.9-.05-177.51-.02-355.01-.02-532.52v-9.26c10,3.28,18.99,5.96,27.69,9.36,1.36.53,1.99,4.36,2.02,6.68.17,14.17.25,28.35-.05,42.51-.11,5.26,1.68,7.95,6.82,9.47,15.55,4.58,30.97,9.59,47.4,14.73.21-2.91.48-5.06.49-7.22.1-14.17.02-28.35.23-42.52.15-10.07,3.69-12.79,13.08-9.76,17.45,5.63,34.83,11.45,52.15,17.48,9.32,3.24,12.66,8.43,12.67,18.46.03,35.78-.07,71.56.05,107.34.03,7.56-2.6,13.42-8.55,18.1-9.31,7.32-18.32,15.04-27.71,22.25-3.47,2.67-4.92,5.42-4.91,9.88.11,138.01.03,276.02.1,414.03.01,32.23-2.08,64.19-14.18,94.47-24.52,61.33-67.45,101.93-133.21,115.85-9.24,1.96-18.91,2.61-28.38,2.64-67.61.24-135.22.17-202.83.18-2.22,0-4.44-.24-8.67-.49ZM707.67,578.13c0-28.19.75-55.54-.53-82.79-.32-6.88-5.92-14.7-11.24-19.89-10.81-10.58-24.41-6.66-29.84,7.52-1.06,2.78-1.94,5.84-1.96,8.77-.15,24.56-.09,49.13-.09,74.2,14.53,4.06,28.94,8.09,43.64,12.2Z" />
<path d="M550.29,611.15h-140.74c-.15-3.18-.37-5.67-.37-8.15-.02-58.79.02-117.58-.07-176.37,0-4.36.43-7.09,5.61-8.45,58.86-15.42,117.64-31.12,176.45-46.74.42-.11.9-.01,2.63-.01,0,2.81,0,5.68,0,8.54-.03,125.94.18,251.88-.23,377.83-.13,40.79-16.04,74.87-47.7,101.19-78.26,65.05-156.45,130.16-234.85,195.04-3.21,2.66-8.17,4.4-12.36,4.49-21.32.45-42.66.21-66.02.21,3.09-2.75,4.81-4.38,6.64-5.88,71.94-58.84,143.89-117.65,215.82-176.5,19.42-15.89,38.64-32.02,58.12-47.83,25.37-20.58,37.33-47.15,37.14-79.7-.26-43.22-.07-86.44-.08-129.66,0-2.29,0-4.57,0-8Z" />
<path d="M560.11,210.07c0-7.96,0-15.13,0-22.31.01-55.3.02-110.61.03-165.91,0-13.02,7.86-22.02,19.1-21.85,11.18.17,18.62,8.67,18.62,21.58-.02,58.79,3.25,117.83-1.19,176.29-3.69,48.59-.74,96.93-2.82,145.33-.24,5.6-3.85,5.64-7.21,6.51-38.68,10.01-77.36,19.98-116.08,29.85-26.11,6.66-52.25,13.21-78.42,19.65-13.71,3.38-24.86-5.34-24.87-19.28-.03-34.86.01-69.71.07-104.57.02-12.2,5.31-18.61,17.17-21.91,16.55-4.6,33.02-9.5,49.53-14.27,12.34-3.56,18.24.81,18.31,13.64.07,11.85-.05,23.7.09,35.55.09,7.1,1.61,8.22,8.64,6.39,14.15-3.7,28.21-7.77,42.4-11.3,5.37-1.34,6.93-4.22,6.84-9.43-.26-14.4.02-28.81-.19-43.22-.1-6.94,2.65-10.63,9.69-11.98,13.22-2.53,26.31-5.68,40.3-8.76Z" />
<path d="M361.32,924.39c-47.66,5.68-95.31,11.36-143.75,17.13-.12,2.35-.34,4.79-.35,7.24-.04,23.47.13,46.94-.25,70.41-.05,3.11-2.01,7.08-4.39,9.12-10.77,9.2-21.85,18.06-33.17,26.57-2.88,2.16-7.06,3.65-10.65,3.67-49.49.29-98.99.3-148.49.31-13.39,0-20.24-6.84-20.25-20.15-.04-31.6-.03-63.21.03-94.81.03-13.68,7.96-21.79,21.61-21.78,95.97.05,191.94.2,287.91.31,17.15.02,34.3,0,51.45,0,.1.66.2,1.33.3,1.99Z" />
<path d="M179.64,881.76c0-27.43-.27-54.36.13-81.28.16-11.16,9.64-18.62,21.94-18.62,52.72.02,105.44.02,158.16.07,44.36.04,88.72.15,133.07.23,3.93,0,7.87,0,11.94,1.58-41.18,4.92-82.37,9.83-124.17,14.82v83.19h-201.07Z" />
<path d="M541.46,651.31v11.92h-63.14v83.22h-180.91c-.16-2.11-.45-4.12-.45-6.13-.03-21.82-.06-43.65.02-65.47.05-15.26,8.51-23.64,23.77-23.63,70.81.02,141.62.06,212.43.1,2.51,0,5.02,0,8.29,0Z" />
<path d="M789.79,68.43c-21.66,23.89-42.73,46.06-74.45,52.96-7.27,1.58-15.63,2.2-22.63.21-25.49-7.24-50.59-9.24-76.16,1.67-.36-2.34-.77-3.82-.79-5.3-.28-22.74-.36-45.48-.88-68.22-.11-4.86,1.6-6.76,6.03-8.28,34.87-11.94,67.86-10,98.71,11.86,19.17,13.58,40.53,19.02,63.96,15.43,1.5-.23,3.04-.17,6.2-.33Z" />
<path d="M230.89,504.03c27.49.25,49.21,22.49,48.96,50.14-.27,28.73-22.72,51.45-50.58,51.18-27.41-.27-49.86-23.56-49.59-51.46.26-27.63,23.35-50.1,51.22-49.85Z" />
</svg>
);
}
export { StepperLogo };