## Wizard Steps — Navigation Transition knows forward from back. Docs: https://www.interior.dev/docs/wizard-steps Reference: https://www.interior.dev/reference/wizard-steps License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/wizard-steps.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { WizardSteps, type WizardStep } from "@/components/interior/wizard-steps"; export function WorkspaceOnboarding({ onDone }: { onDone: (v: { name: string; team: string }) => void }) { const [value, setValue] = useState({ name: "", team: "" }); const steps: WizardStep[] = [ { id: "profile", label: "Your profile", content: ( setValue((v) => ({ ...v, name: e.target.value }))} placeholder="Full name" className="h-9 w-full rounded-[9px] border border-stone-200 px-3 text-[13px] outline-none dark:border-white/[0.16] dark:bg-[#1D1D1A]" /> ), }, { id: "team", label: "Invite your team", content: ( setValue((v) => ({ ...v, team: e.target.value }))} placeholder="one email per line" className="h-24 w-full resize-none rounded-[9px] border border-stone-200 p-3 text-[13px] outline-none dark:border-white/[0.16] dark:bg-[#1D1D1A]" /> ), }, { id: "review", label: "Review", content: {value.name || "Unnamed"} — {value.team.split("\n").filter(Boolean).length} invites queued., }, ]; return ( onDone(value)} /> ); } ``` ### Props - `steps` (`WizardStep[]`) Ordered steps, each { id, label, content }. The id keys the panel, so it must be stable across renders. - `index` (`number`) Controlled step. Direction is still derived from the previous value, so external navigation animates the right way. - `defaultIndex` (`number`) — default: `0` Uncontrolled starting step, clamped into range. - `onIndexChange` (`(index: number, direction: 1 | -1) => void`) Fires with the step and the direction of travel, never on a no-op move. - `onComplete` (`() => void`) Fires when the primary button is pressed on the last step. - `height` (`number`) — default: `184` Fixed height of the panel viewport in px. Content taller than this scrolls inside it. - `backLabel` (`string`) — default: `"Back"` Label for the secondary button, which is disabled rather than removed on the first step. - `nextLabel` (`string`) — default: `"Next"` Primary label before the last step. - `finishLabel` (`string`) — default: `"Finish"` Primary label on the last step. Shares a grid cell with nextLabel so the button never resizes. - `label` (`string`) — default: `"Steps"` Accessible name for the step marker list. - `className` (`string`) — default: `""` Appended last, so callers can override any of the outer classes. ### Behavior notes - The index and the direction are held in one state object, so no frame can render a new step with the previous direction — the bug where pressing Back slides forward for one frame and then corrects itself. - The panel viewport is a fixed height with its own scroll, so a two-line step and a twenty-line step do not resize the page mid-transition and the Back button stays where the cursor left it. - Next and Finish occupy the same grid cell and only opacity moves between them, so reaching the last step never changes the button's width. - Under prefers-reduced-motion the panel still swaps and the markers still fill; only the travel and the blur are dropped, and nothing is hidden. - Back and Next move focus into the new panel, arrow keys move focus along the step markers, and markers past the furthest step reached stay disabled so nobody lands on a step they have not filled in. - Screen readers get one polite announcement per step — "Step 2 of 4: Billing details" — instead of a stream of updates while the panel is in flight; the behaviour also ships alone as useWizard for anyone drawing their own chrome. ### Source (`components/interior/wizard-steps.tsx`) ```tsx "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent, ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const EASE = [0.23, 1, 0.32, 1] as const; const EXIT_EASE = [0.4, 0, 1, 1] as const; const RAIL = { type: "spring", stiffness: 520, damping: 40, mass: 0.5 } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; export type WizardDirection = 1 | -1; export type UseWizardOptions = { total: number; index?: number; defaultIndex?: number; onIndexChange?: (index: number, direction: WizardDirection) => void; onComplete?: () => void; }; export type UseWizardReturn = { index: number; direction: WizardDirection; furthest: number; total: number; isFirst: boolean; isLast: boolean; next: () => void; back: () => void; goTo: (index: number) => void; }; function clampIndex(value: number, total: number) { if (total < 1) return 0; return Math.max(0, Math.min(total - 1, Math.trunc(value))); } export function useWizard({ total, index, defaultIndex = 0, onIndexChange, onComplete, }: UseWizardOptions): UseWizardReturn { const [internal, setInternal] = useState(() => clampIndex(defaultIndex, total)); const current = clampIndex(index ?? internal, total); const [seen, setSeen] = useState<{ index: number; direction: WizardDirection }>({ index: current, direction: 1, }); if (seen.index !== current) { setSeen({ index: current, direction: current > seen.index ? 1 : -1 }); } const [furthest, setFurthest] = useState(current); if (furthest < current) setFurthest(current); const emit = useRef(onIndexChange); emit.current = onIndexChange; const finish = useRef(onComplete); finish.current = onComplete; const controlled = index !== undefined; const goTo = useCallback( (to: number) => { const target = clampIndex(to, total); if (target === current) return; const direction: WizardDirection = target > current ? 1 : -1; if (!controlled) setInternal(target); emit.current?.(target, direction); }, [controlled, current, total], ); const next = useCallback(() => { if (current >= total - 1) { finish.current?.(); return; } goTo(current + 1); }, [current, goTo, total]); const back = useCallback(() => goTo(current - 1), [current, goTo]); return { index: current, direction: seen.direction, furthest: Math.min(furthest, Math.max(total - 1, 0)), total, isFirst: current === 0, isLast: current === total - 1, next, back, goTo, }; } export type WizardStep = { id: string; label: string; content: ReactNode; }; export type WizardStepsProps = { steps: WizardStep[]; index?: number; defaultIndex?: number; onIndexChange?: (index: number, direction: WizardDirection) => void; onComplete?: () => void; complete?: boolean; height?: number; backLabel?: string; nextLabel?: string; finishLabel?: string; completeLabel?: string; completeHint?: string; label?: string; className?: string; }; export function WizardSteps({ steps, index, defaultIndex = 0, onIndexChange, onComplete, complete = false, height = 184, backLabel = "Back", nextLabel = "Next", finishLabel = "Finish", completeLabel = "All set", completeHint = "Step back to change anything", label = "Steps", className = "", }: WizardStepsProps) { const wizard = useWizard({ total: steps.length, index, defaultIndex, onIndexChange, onComplete, }); const reduced = useReducedMotion(); const listRef = useRef(null); const viewportRef = useRef(null); const intent = useRef<"list" | "panel" | null>(null); const { index: at, direction, furthest, total, isFirst, isLast, next, back, goTo } = wizard; useEffect(() => { const move = intent.current; intent.current = null; if (move === "list") { listRef.current ?.querySelector('button[data-current="true"]') ?.focus(); return; } if (move === "panel") viewportRef.current?.focus({ preventScroll: true }); }, [at]); const variants = useMemo( () => ({ enter: (d: WizardDirection) => (reduced ? { opacity: 0 } : { opacity: 0, x: d * 22 }), center: reduced ? { opacity: 1 } : { opacity: 1, x: 0 }, exit: (d: WizardDirection) => reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, x: d * -22, transition: { duration: 0.14, ease: EXIT_EASE }, }, }), [reduced], ); const panelTransition = reduced ? { duration: 0 } : CROSSFADE; const onStepKeyDown = (e: KeyboardEvent) => { let target = at; if (e.key === "ArrowRight" || e.key === "ArrowDown") target = at + 1; else if (e.key === "ArrowLeft" || e.key === "ArrowUp") target = at - 1; else if (e.key === "Home") target = 0; else if (e.key === "End") target = furthest; else return; e.preventDefault(); target = Math.min(clampIndex(target, total), furthest); if (target === at) return; intent.current = "list"; goTo(target); }; const step = steps[at]; if (!step) return null; const position = `Step ${at + 1} of ${total}: ${step.label}`; return ( {position} {steps.map((s, i) => ( {s.label} ))} {steps.map((s, i) => { const done = complete || i < at; const here = !complete && i === at; const tile = ( {done ? ( ) : ( i + 1 )} ); return ( {i <= furthest ? ( { if (here) return; intent.current = "list"; goTo(i); }} className="rounded-[8px] outline-none focus-visible:shadow-[0_0_0_1.5px_#4568FF] dark:focus-visible:shadow-[0_0_0_1.5px_#93B0FF]" > {tile} ) : ( {`Step ${i + 1} of ${total}: ${s.label}`} {tile} )} {i < total - 1 ? ( ) : null} ); })} {complete ? ( {completeLabel} {completeHint} ) : ( step.content )} {isFirst ? null : ( { intent.current = "panel"; back(); }} className="h-9 rounded-[9px] border border-stone-200 bg-white px-3 text-[13px] font-medium text-stone-700 outline-none transition-[border-color,box-shadow] duration-150 hover:border-stone-300 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:hover:border-white/20 dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)]" > {backLabel} )} {complete ? null : ( { if (!isLast) intent.current = "panel"; next(); }} initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.96, transition: reduced ? { duration: 0 } : { duration: 0.14, ease: EXIT_EASE }, }} transition={reduced ? { duration: 0 } : CROSSFADE} className="ml-auto grid h-9 place-items-center rounded-[9px] bg-stone-800 px-3.5 text-[13px] font-medium text-white outline-none focus-visible:shadow-[inset_0_0_0_1.5px_#93B0FF] dark:bg-stone-100 dark:text-stone-900 dark:focus-visible:shadow-[inset_0_0_0_1.5px_#4568FF]" > {finishLabel.length > nextLabel.length ? finishLabel : nextLabel} {nextLabel} {finishLabel} )} ); } ```
{value.name || "Unnamed"} — {value.team.split("\n").filter(Boolean).length} invites queued.
{position}
{completeLabel}
{completeHint}