{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stepper",
  "type": "registry:ui",
  "title": "Stepper",
  "description": "Controlled multi-step wizard — a useStepper hook plus Stepper / StepperNav / StepperPanel with linear or non-linear flow, optional steps, and async validation gating.",
  "dependencies": [
    "@saasflare/ui",
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/stepper.tsx",
      "type": "registry:ui",
      "content": "\"use client\"\n\n/**\n * @fileoverview Stepper — a controlled/uncontrolled multi-step wizard. Pairs the\n * existing visual `Steps`/`Step` indicator with content `StepperPanel`s and a\n * built-in `StepperNav` (Back / Next / Finish). State is driven by the headless\n * `useStepper` hook — bring your own instance via `stepper={...}` (controlled)\n * or let `Stepper` own one (uncontrolled). Panel transitions use JS motion\n * (Pattern A) gated by `useSaasflareMotion`.\n * @author Saasflare™\n * @module packages/ui/components/ui/stepper\n * @package ui\n *\n * @component\n * @example\n * import { Stepper, StepperPanel } from \"@saasflare/ui\";\n *\n * <Stepper\n *   items={[{ title: \"Account\" }, { title: \"Profile\" }, { title: \"Done\" }]}\n * >\n *   <StepperPanel value={0}>Account fields…</StepperPanel>\n *   <StepperPanel value={1}>Profile fields…</StepperPanel>\n *   <StepperPanel value={2}>All set!</StepperPanel>\n * </Stepper>\n */\n\nimport {\n    Children,\n    createContext,\n    isValidElement,\n    useContext,\n    useEffect,\n    useId,\n    useMemo,\n    useRef,\n    type KeyboardEvent,\n    type ReactElement,\n    type ReactNode,\n} from \"react\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport { cn } from \"@saasflare/ui\"\nimport { useSaasflareProps, type SaasflareComponentProps } from \"@saasflare/ui\"\nimport { useStepper, type UseStepperReturn } from \"@saasflare/ui\"\nimport { springGentle, useSaasflareMotion } from \"@saasflare/ui\"\nimport { Steps, Step } from \"@saasflare/ui\"\nimport { Button } from \"@saasflare/ui\"\nimport { CaretLeftIcon, CaretRightIcon, CheckIcon, WarningIcon } from \"@saasflare/ui\"\nimport type { ResolvedSaasflareProps } from \"@saasflare/ui\"\n\n/**\n * One step descriptor for the Stepper indicator (mirrors the visual `Step`).\n *\n * @example\n * const items: StepperItem[] = [{ title: \"Plan\" }, { title: \"Add-ons\", optional: true }];\n */\nexport interface StepperItem {\n    /** Title shown in the indicator. */\n    title: string\n    /** Optional sub-description. */\n    description?: string\n    /** Optional icon replacing the number. */\n    icon?: ReactNode\n    /** Marks the step skippable. */\n    optional?: boolean\n}\n\n/**\n * Props for {@link Stepper}.\n *\n * @example\n * <Stepper items={items} linear={false} mountMode=\"keepMounted\">…</Stepper>\n */\nexport interface StepperProps extends SaasflareComponentProps {\n    /** Step metadata for the indicator. Length defines the step count. */\n    items: ReadonlyArray<StepperItem>\n    /** Indicator layout (forwarded to `Steps`). Default: `\"horizontal\"` */\n    direction?: \"horizontal\" | \"vertical\"\n    /** Linear mode forwarded to the internal hook. Default: `true` */\n    linear?: boolean\n    /** Bring-your-own hook instance (controlled). When omitted, Stepper owns one. */\n    stepper?: UseStepperReturn\n    /** Initial step when Stepper owns the hook (uncontrolled). Default: `0` */\n    defaultStep?: number\n    /** Global validation gate (uncontrolled mode only). */\n    validate?: (\n        step: number,\n    ) => boolean | string | void | Promise<boolean | string | void>\n    /** Fired on finish (uncontrolled mode only). */\n    onComplete?: () => void\n    /** `\"unmount\"` = only active panel in DOM; `\"keepMounted\"` = all panels mounted, inactive hidden. Default: `\"unmount\"` */\n    mountMode?: \"unmount\" | \"keepMounted\"\n    /** Hide the built-in `<StepperNav>`. Default: `false` */\n    hideNav?: boolean\n    /** Panel children — one `<StepperPanel>` per step, in order. */\n    children: ReactNode\n    /** Class for the root. */\n    className?: string\n}\n\n/**\n * Content panel for a single step. Renders only when active (or stays mounted\n * and hidden in `keepMounted` mode).\n *\n * @example\n * <StepperPanel value={1}><ProfileForm /></StepperPanel>\n */\nexport interface StepperPanelProps {\n    /** 0-based index this panel maps to. */\n    value: number\n    /** Panel content. */\n    children: ReactNode\n    /** Class for the panel. */\n    className?: string\n}\n\n/**\n * Built-in navigation row (Back / Next / Finish). Auto-rendered unless `hideNav`.\n *\n * @example\n * <StepperNav nextLabel=\"Continue\" finishLabel=\"Submit\" />\n */\nexport interface StepperNavProps {\n    /** Override Back label. Default: `\"Back\"` */\n    backLabel?: ReactNode\n    /** Override Next label. Default: `\"Next\"` */\n    nextLabel?: ReactNode\n    /** Override Finish (last-step Next) label. Default: `\"Finish\"` */\n    finishLabel?: ReactNode\n    /** Show a \"Skip\" button on optional steps. Default: `true` */\n    allowSkip?: boolean\n    /** Class for the nav row. */\n    className?: string\n}\n\n/**\n * Render-prop access to the active Stepper's hook (for custom footers/headers).\n *\n * @example\n * <StepperContent>{(s) => <p>Step {s.activeStep + 1} of {s.count}</p>}</StepperContent>\n */\nexport interface StepperContentProps {\n    /** Render-prop receiving the active stepper instance. */\n    children: (stepper: UseStepperReturn) => ReactNode\n}\n\n/* ── Internal context (not exported) ── */\n\ninterface StepperContextValue {\n    stepper: UseStepperReturn\n    items: ReadonlyArray<StepperItem>\n    linear: boolean\n    direction: \"horizontal\" | \"vertical\"\n    /** Stable id namespace for ARIA wiring between step buttons and panels. */\n    baseId: string\n    /** Resolved theme axes, forwarded to nav Buttons. */\n    sf: ResolvedSaasflareProps\n}\n\nconst StepperContext = createContext<StepperContextValue | null>(null)\n\n/** Reads the nearest Stepper context, throwing a helpful error if absent. */\nfunction useStepperContext(component: string): StepperContextValue {\n    const ctx = useContext(StepperContext)\n    if (ctx === null) {\n        throw new Error(\n            `[Saasflare][Stepper] <${component}> must be rendered inside <Stepper>.`,\n        )\n    }\n    return ctx\n}\n\n/** Builds the DOM id for a step's trigger button. */\nfunction stepButtonId(baseId: string, i: number): string {\n    return `${baseId}-step-${i}`\n}\n\n/** Builds the DOM id for a step's content panel. */\nfunction stepPanelId(baseId: string, i: number): string {\n    return `${baseId}-panel-${i}`\n}\n\n/**\n * Primary multi-step wizard. Resolves theme axes via {@link useSaasflareProps}\n * and emits `data-surface`/`data-radius`/`data-animated` on its root. Owns a\n * {@link useStepper} instance unless one is supplied via `stepper`.\n *\n * Indicator step circles become focusable triggers only in non-linear mode\n * (roving tabindex, Arrow/Home/End/Enter/Space); in linear mode they are\n * non-interactive and `aria-disabled` to enforce order. On step change, focus\n * moves to the newly active panel (skipped on initial mount).\n *\n * @component\n * @layer ui\n *\n * @param {ReadonlyArray<StepperItem>} items - Indicator metadata; length = step count.\n * @param {string} direction - Indicator layout: \"horizontal\" | \"vertical\".\n * @param {boolean} linear - Forbid forward jumps past incomplete required steps.\n * @param {UseStepperReturn} stepper - External hook instance (controlled).\n * @param {string} mountMode - \"unmount\" | \"keepMounted\".\n *\n * @example\n * <Stepper items={[{ title: \"A\" }, { title: \"B\" }]}>\n *   <StepperPanel value={0}>A</StepperPanel>\n *   <StepperPanel value={1}>B</StepperPanel>\n * </Stepper>\n *\n * @example\n * // Controlled: drive an external hook instance\n * const s = useStepper({ count: 3 });\n * <Stepper items={items} stepper={s}>…</Stepper>\n */\nexport function Stepper({\n    items,\n    direction = \"horizontal\",\n    linear = true,\n    stepper: externalStepper,\n    defaultStep = 0,\n    validate,\n    onComplete,\n    mountMode = \"unmount\",\n    hideNav = false,\n    children,\n    className,\n    surface,\n    radius,\n    animated,\n    iconWeight,\n}: StepperProps) {\n    const sf = useSaasflareProps({ surface, radius, animated, iconWeight })\n\n    const stepConfig = useMemo(\n        () => items.map((item) => ({ optional: item.optional })),\n        [items],\n    )\n\n    // Always call the hook to satisfy the rules-of-hooks; ignore its result when\n    // an external instance is provided.\n    const ownedStepper = useStepper({\n        steps: stepConfig,\n        defaultStep,\n        linear,\n        validate,\n        onComplete,\n    })\n    const stepper = externalStepper ?? ownedStepper\n\n    const baseId = useId()\n\n    const ctx = useMemo<StepperContextValue>(\n        () => ({ stepper, items, linear, direction, baseId, sf }),\n        [stepper, items, linear, direction, baseId, sf],\n    )\n\n    const motion = useSaasflareMotion(sf.animated, springGentle)\n\n    // Collect panels keyed by their declared `value` so we can render the active\n    // one (unmount mode) or all of them (keepMounted mode).\n    const panels = useMemo(() => {\n        const map = new Map<number, ReactElement<StepperPanelProps>>()\n        Children.forEach(children, (child) => {\n            if (isValidElement(child) && child.type === StepperPanel) {\n                const panel = child as ReactElement<StepperPanelProps>\n                map.set(panel.props.value, panel)\n            }\n        })\n        return map\n    }, [children])\n\n    // Focus the newly active panel on step change (not on first mount).\n    const rootRef = useRef<HTMLDivElement>(null)\n    const mountedRef = useRef(false)\n    useEffect(() => {\n        if (!mountedRef.current) {\n            mountedRef.current = true\n            return\n        }\n        const node = rootRef.current?.querySelector<HTMLElement>(\n            `#${CSS.escape(stepPanelId(baseId, stepper.activeStep))}`,\n        )\n        node?.focus()\n    }, [stepper.activeStep, baseId])\n\n    const activePanel = panels.get(stepper.activeStep)\n\n    return (\n        <StepperContext.Provider value={ctx}>\n            <div\n                ref={rootRef}\n                className={cn(\"flex flex-col gap-6\", className)}\n                data-slot=\"stepper\"\n                data-surface={sf.surface}\n                data-radius={sf.radius}\n                data-animated={String(sf.animated)}\n                role=\"group\"\n                aria-label=\"Progress\"\n            >\n                <StepperIndicator />\n\n                <div className=\"relative\">\n                    {mountMode === \"keepMounted\" ? (\n                        // All panels mounted; inactive ones hidden so AT skips them.\n                        Array.from(panels.entries()).map(([value, panel]) => (\n                            <PanelRegion\n                                key={value}\n                                value={value}\n                                active={value === stepper.activeStep}\n                                baseId={baseId}\n                                className={panel.props.className}\n                            >\n                                {panel.props.children}\n                            </PanelRegion>\n                        ))\n                    ) : (\n                        <AnimatePresence mode=\"wait\" initial={false}>\n                            {activePanel ? (\n                                <m.div\n                                    key={stepper.activeStep}\n                                    initial={\n                                        motion.disabled\n                                            ? false\n                                            : { opacity: 0, x: stepper.direction * 12 }\n                                    }\n                                    animate={{ opacity: 1, x: 0 }}\n                                    exit={\n                                        motion.disabled\n                                            ? { opacity: 1, x: 0 }\n                                            : { opacity: 0, x: stepper.direction * -12 }\n                                    }\n                                    transition={motion.transition}\n                                >\n                                    <PanelRegion\n                                        value={stepper.activeStep}\n                                        active\n                                        baseId={baseId}\n                                        className={activePanel.props.className}\n                                    >\n                                        {activePanel.props.children}\n                                    </PanelRegion>\n                                </m.div>\n                            ) : null}\n                        </AnimatePresence>\n                    )}\n                </div>\n\n                {!hideNav && <StepperNav />}\n            </div>\n        </StepperContext.Provider>\n    )\n}\n\n/**\n * The indicator row: renders the untouched visual `Steps`/`Step` and overlays\n * focusable trigger buttons (non-linear) or `aria-disabled` placeholders\n * (linear) with roving tabindex + arrow-key navigation. Internal to `Stepper`.\n */\nfunction StepperIndicator() {\n    const { stepper, items, linear, direction, baseId, sf } =\n        useStepperContext(\"StepperIndicator\")\n\n    const listRef = useRef<HTMLDivElement>(null)\n\n    /** Whether a step index can receive focus / be activated by goTo. */\n    const isReachable = (i: number): boolean => {\n        if (!linear) return true\n        if (i <= stepper.activeStep) return true\n        // Forward reachability mirrors the hook's linear goTo guard.\n        for (let j = stepper.activeStep; j < i; j++) {\n            if (!stepper.isOptional(j) && !stepper.isCompleted(j)) return false\n        }\n        return true\n    }\n\n    const moveFocus = (toIndex: number) => {\n        const buttons = listRef.current?.querySelectorAll<HTMLButtonElement>(\n            \"[data-stepper-trigger]\",\n        )\n        buttons?.[toIndex]?.focus()\n    }\n\n    const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, i: number) => {\n        const horizontal = direction === \"horizontal\"\n        const nextKey = horizontal ? \"ArrowRight\" : \"ArrowDown\"\n        const prevKey = horizontal ? \"ArrowLeft\" : \"ArrowUp\"\n        const last = items.length - 1\n\n        const findNext = (from: number, step: 1 | -1): number => {\n            let i2 = from\n            for (let guard = 0; guard < items.length; guard++) {\n                i2 += step\n                if (i2 < 0 || i2 > last) return from\n                if (isReachable(i2)) return i2\n            }\n            return from\n        }\n\n        if (event.key === nextKey) {\n            event.preventDefault()\n            moveFocus(findNext(i, 1))\n        } else if (event.key === prevKey) {\n            event.preventDefault()\n            moveFocus(findNext(i, -1))\n        } else if (event.key === \"Home\") {\n            event.preventDefault()\n            moveFocus(isReachable(0) ? 0 : findNext(0, 1))\n        } else if (event.key === \"End\") {\n            event.preventDefault()\n            moveFocus(isReachable(last) ? last : findNext(last, -1))\n        } else if (event.key === \"Enter\" || event.key === \" \") {\n            event.preventDefault()\n            if (isReachable(i)) stepper.goTo(i)\n        }\n    }\n\n    const stepLabel = (i: number): string => {\n        const item = items[i]\n        const parts = [`Step ${i + 1} of ${items.length}: ${item.title}`]\n        if (item.optional) parts.push(\"(optional)\")\n        if (stepper.errors.has(i)) parts.push(\"error\")\n        else if (stepper.isCompleted(i)) parts.push(\"completed\")\n        return parts.join(\" \")\n    }\n\n    // The roving-tabindex anchor: the active step is the single tab stop.\n    const tabbableIndex = stepper.activeStep\n\n    return (\n        <div className=\"relative\">\n            {/* Decorative visual indicator — hidden from assistive tech so the\n                interactive overlay below is the single a11y source (otherwise the\n                active step claims aria-current on both the Steps listitem and the\n                trigger button). */}\n            <div aria-hidden=\"true\">\n                <Steps\n                    current={stepper.activeStep}\n                    direction={direction}\n                    surface={sf.surface}\n                    radius={sf.radius}\n                    animated={sf.animated}\n                    iconWeight={sf.iconWeight}\n                >\n                    {items.map((item, i) => (\n                        <Step\n                            key={i}\n                            title={item.title}\n                            description={item.description}\n                            icon={item.icon}\n                            optional={item.optional}\n                        />\n                    ))}\n                </Steps>\n            </div>\n\n            {/* Interactive overlay: a button per step for keyboard + click nav. */}\n            <div\n                ref={listRef}\n                className={cn(\n                    \"absolute inset-0 flex\",\n                    direction === \"horizontal\" ? \"flex-row items-start\" : \"flex-col\",\n                )}\n            >\n                {items.map((item, i) => {\n                    const reachable = isReachable(i)\n                    const active = i === stepper.activeStep\n                    const hasError = stepper.errors.has(i)\n                    return (\n                        <button\n                            key={i}\n                            type=\"button\"\n                            data-stepper-trigger=\"\"\n                            className={cn(\n                                \"flex bg-transparent outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-full\",\n                                direction === \"horizontal\"\n                                    ? \"flex-1 flex-col items-center self-stretch\"\n                                    : \"flex-row gap-4 self-start\",\n                                reachable && !active\n                                    ? \"cursor-pointer\"\n                                    : \"cursor-default\",\n                            )}\n                            // Only the active step is a tab stop (roving tabindex).\n                            tabIndex={i === tabbableIndex ? 0 : -1}\n                            aria-current={active ? \"step\" : undefined}\n                            aria-controls={stepPanelId(baseId, i)}\n                            aria-disabled={reachable ? undefined : true}\n                            aria-invalid={hasError ? true : undefined}\n                            aria-label={stepLabel(i)}\n                            id={stepButtonId(baseId, i)}\n                            disabled={!reachable}\n                            onClick={() => {\n                                if (reachable) stepper.goTo(i)\n                            }}\n                            onKeyDown={(e) => onKeyDown(e, i)}\n                        >\n                            {/* Sized spacer matching the circle so the hit area\n                                covers the indicator; content stays in Steps. */}\n                            <span className=\"size-9 shrink-0\" aria-hidden=\"true\" />\n                        </button>\n                    )\n                })}\n            </div>\n\n            {/* Active-step error, announced politely. */}\n            {stepper.errors.has(stepper.activeStep) && (\n                <p\n                    className=\"mt-2 flex items-center gap-1.5 text-sm text-destructive\"\n                    role=\"alert\"\n                    aria-live=\"polite\"\n                >\n                    <WarningIcon\n                        className=\"size-4 shrink-0\"\n                        weight={sf.iconWeight}\n                        aria-hidden=\"true\"\n                    />\n                    {stepper.errors.get(stepper.activeStep)}\n                </p>\n            )}\n        </div>\n    )\n}\n\n/** A single panel region with the tabpanel-style ARIA wiring. Internal. */\ninterface PanelRegionProps {\n    value: number\n    active: boolean\n    baseId: string\n    className?: string\n    children: ReactNode\n}\n\nfunction PanelRegion({ value, active, baseId, className, children }: PanelRegionProps) {\n    return (\n        <div\n            id={stepPanelId(baseId, value)}\n            role=\"tabpanel\"\n            aria-labelledby={stepButtonId(baseId, value)}\n            tabIndex={-1}\n            hidden={!active}\n            className={cn(\"outline-none focus-visible:ring-2 focus-visible:ring-ring\", className)}\n        >\n            {children}\n        </div>\n    )\n}\n\n/**\n * Content panel for a single step. As a child of {@link Stepper} it is a data\n * carrier: `Stepper` reads its `value`, `children`, and `className` and renders\n * the matching region itself (so it can apply motion + ARIA). Rendering one\n * standalone simply outputs its children in a labelled region.\n *\n * @component\n * @example\n * <StepperPanel value={0}>Step one content</StepperPanel>\n */\nexport function StepperPanel(props: StepperPanelProps): ReactNode {\n    // When rendered directly (not collected by Stepper), emit a plain region so\n    // the node is never lost. Stepper itself never renders this branch — it\n    // reads props off the element and renders <PanelRegion> with motion/ARIA.\n    return (\n        <div data-slot=\"stepper-panel\" className={props.className}>\n            {props.children}\n        </div>\n    )\n}\n\n/**\n * Built-in navigation row: Back, an optional Skip (on optional steps), and a\n * Next button that becomes Finish on the last step. Reads the active stepper\n * from context and inherits the wizard's theme axes. Next is disabled while an\n * async gate is validating.\n *\n * @component\n * @example\n * <Stepper items={items} hideNav>\n *   …panels…\n *   <StepperNav nextLabel=\"Continue\" />\n * </Stepper>\n */\nexport function StepperNav({\n    backLabel = \"Back\",\n    nextLabel = \"Next\",\n    finishLabel = \"Finish\",\n    allowSkip = true,\n    className,\n}: StepperNavProps): ReactNode {\n    const { stepper, sf } = useStepperContext(\"StepperNav\")\n\n    const showSkip =\n        allowSkip && stepper.isOptional(stepper.activeStep) && !stepper.isLast\n\n    return (\n        <div className={cn(\"flex items-center justify-between gap-3\", className)}>\n            <Button\n                variant=\"ghost\"\n                intent=\"neutral\"\n                surface={sf.surface}\n                radius={sf.radius}\n                animated={sf.animated}\n                iconWeight={sf.iconWeight}\n                startContent={\n                    <CaretLeftIcon weight={sf.iconWeight} aria-hidden=\"true\" />\n                }\n                disabled={!stepper.canBack}\n                onClick={() => stepper.back()}\n            >\n                {backLabel}\n            </Button>\n\n            <div className=\"flex items-center gap-2\">\n                {showSkip && (\n                    <Button\n                        variant=\"ghost\"\n                        intent=\"neutral\"\n                        surface={sf.surface}\n                        radius={sf.radius}\n                        animated={sf.animated}\n                        iconWeight={sf.iconWeight}\n                        onClick={() => stepper.goTo(stepper.activeStep + 1)}\n                    >\n                        Skip\n                    </Button>\n                )}\n\n                <Button\n                    intent=\"primary\"\n                    surface={sf.surface}\n                    radius={sf.radius}\n                    animated={sf.animated}\n                    iconWeight={sf.iconWeight}\n                    isLoading={stepper.isValidating}\n                    endContent={\n                        stepper.isLast ? (\n                            <CheckIcon weight={sf.iconWeight} aria-hidden=\"true\" />\n                        ) : (\n                            <CaretRightIcon weight={sf.iconWeight} aria-hidden=\"true\" />\n                        )\n                    }\n                    onClick={() => {\n                        void stepper.next()\n                    }}\n                >\n                    {stepper.isLast ? finishLabel : nextLabel}\n                </Button>\n            </div>\n        </div>\n    )\n}\n\n/**\n * Render-prop escape hatch exposing the active Stepper's hook instance, for\n * custom headers/footers/summaries outside the built-in nav.\n *\n * @component\n * @example\n * <Stepper items={items}>\n *   …panels…\n *   <StepperContent>\n *     {(s) => <p className=\"text-sm text-muted-foreground\">{s.activeStep + 1}/{s.count}</p>}\n *   </StepperContent>\n * </Stepper>\n */\nexport function StepperContent({ children }: StepperContentProps): ReactNode {\n    const { stepper } = useStepperContext(\"StepperContent\")\n    return children(stepper)\n}\n",
      "target": "components/ui/stepper.tsx"
    }
  ],
  "$meta": {
    "source": "https://ui.saasflare.io/r/stepper.json"
  }
}
