Step Wizard

Steps slide the way you move, forward or back.

1 / 0

Invite your team

Bring collaborators in. Everyone gets their own workspace view.

"use client"

import {
  addTransitionType,
  Children,
  createContext,
  isValidElement,
  startTransition,
  useContext,
  useLayoutEffect,
  useState,
  ViewTransition,
  type ReactNode,
} from "react"

const icon = {
  width: 16,
  height: 16,
  viewBox: "0 0 16 16",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 1.5,
  strokeLinecap: "round",
  strokeLinejoin: "round",
  "aria-hidden": true,
} as const
const ArrowLeft = ({ className }: { className?: string }) => (
  <svg {...icon} className={className}>
    <path d="M13 8H3.5" />
    <path d="M7.5 4 3.5 8l4 4" />
  </svg>
)
const ArrowRight = ({ className }: { className?: string }) => (
  <svg {...icon} className={className}>
    <path d="M3 8h9.5" />
    <path d="M8.5 4l4 4-4 4" />
  </svg>
)

type Shape = { count: number; hasDone: boolean }

type WizardState = {
  step: number
  count: number
  done: boolean
  hasDone: boolean
  next: () => void
  back: () => void
  restart: () => void
  setShape: (shape: Shape) => void
}

const Context = createContext<WizardState | null>(null)

export function useStepWizard() {
  const wizard = useContext(Context)
  if (!wizard) throw new Error("StepWizard parts must be used inside <StepWizard>")
  return wizard
}

const button =
  "inline-flex shrink-0 cursor-pointer items-center justify-center font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 h-7 gap-1 rounded-md border border-transparent px-2.5 text-[0.8rem] [&_svg]:size-3.5"
const outline =
  "border border-border bg-background hover:bg-muted dark:border-input dark:bg-input/30 dark:hover:bg-input/50"

export function StepWizard({
  defaultStep = 0,
  onStepChange,
  className = "",
  children,
}: {
  defaultStep?: number
  onStepChange?: (step: number) => void
  className?: string
  children: ReactNode
}) {
  const [index, setIndex] = useState(defaultStep)
  const [{ count, hasDone }, setShape] = useState<Shape>({ count: 0, hasDone: false })
  const last = hasDone ? count : count - 1
  const step = Math.max(0, Math.min(index, last))

  const move = (direction: "next" | "prev", to: number) => {
    if (to === step || to < 0 || to > last) return
    document.activeViewTransition?.skipTransition()
    startTransition(() => {
      addTransitionType(`wizard-${direction}`)
      setIndex(to)
      onStepChange?.(to)
    })
  }

  const state: WizardState = {
    step,
    count,
    done: hasDone && step === count,
    hasDone,
    next: () => move("next", step + 1),
    back: () => move("prev", step - 1),
    restart: () => move("prev", 0),
    setShape,
  }

  return (
    <Context value={state}>
      <div
        className={`w-full max-w-sm rounded-2xl border border-border bg-card p-5 text-card-foreground ${className}`}
      >
        {children}
      </div>
    </Context>
  )
}

export function StepWizardProgress({
  doneLabel = "Done",
  className = "",
}: {
  doneLabel?: string
  className?: string
}) {
  const { step, count, done } = useStepWizard()
  return (
    <div className={`mb-5 flex items-center gap-1.5 ${className}`}>
      {Array.from({ length: count }, (_, i) => (
        <span
          key={i}
          className={`h-1.5 rounded-full transition-[width,background-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] ${
            !done && i === step
              ? "w-6 bg-foreground"
              : i < step || done
                ? "w-1.5 bg-foreground"
                : "w-1.5 bg-muted"
          }`}
        />
      ))}
      <span className="ml-auto text-xs text-muted-foreground tabular-nums">
        {done ? doneLabel : `${step + 1} / ${count}`}
      </span>
    </div>
  )
}

export function StepWizardContent({
  className = "",
  children,
}: {
  className?: string
  children: ReactNode
}) {
  const { step, setShape } = useStepWizard()
  const items = Children.toArray(children).filter(isValidElement)
  const steps = items.filter((item) => item.type !== StepWizardDone)
  const done = items.find((item) => item.type === StepWizardDone)
  const count = steps.length
  const hasDone = Boolean(done)

  useLayoutEffect(() => setShape({ count, hasDone }), [count, hasDone, setShape])

  return (
    <ViewTransition
      update={{
        "wizard-next": "vt-slide vt-clip vt-forward",
        "wizard-prev": "vt-slide vt-clip vt-back",
        default: "none",
      }}
    >
      <div className={`-mx-5 h-32 px-5 ${className}`} aria-live="polite">
        {step >= count ? done : steps[step]}
      </div>
    </ViewTransition>
  )
}

export function StepWizardStep({ children }: { children: ReactNode }) {
  return <>{children}</>
}

export function StepWizardDone({ children }: { children: ReactNode }) {
  return <>{children}</>
}

export function StepWizardBack({
  className = "",
  children,
}: {
  className?: string
  children?: ReactNode
}) {
  const { step, back } = useStepWizard()
  return (
    <button
      type="button"
      onClick={back}
      disabled={step === 0}
      className={`${button} bg-secondary text-secondary-foreground hover:bg-secondary/80 ${className}`}
    >
      {children ?? (
        <>
          <ArrowLeft /> Back
        </>
      )}
    </button>
  )
}

export function StepWizardNext({
  finishLabel = "Finish",
  restartLabel = "Start over",
  className = "",
  children,
}: {
  finishLabel?: string
  restartLabel?: string
  className?: string
  children?: ReactNode
}) {
  const { step, count, done, hasDone, next, restart } = useStepWizard()
  if (done)
    return (
      <button type="button" onClick={restart} className={`${button} ${outline} ${className}`}>
        {restartLabel}
      </button>
    )
  const finishing = step === count - 1
  return (
    <button
      type="button"
      onClick={next}
      disabled={finishing && !hasDone}
      className={`${button} bg-primary text-primary-foreground hover:bg-primary/80 ${className}`}
    >
      {finishing ? finishLabel : (children ?? "Continue")} <ArrowRight />
    </button>
  )
}

Installation

  1. 1Copy the Component tab into components/step-wizard.tsx.
  2. 2Paste the CSS tab into globals.css, after Tailwind.

How it works

StepWizardContent shows one StepWizardStep at a time, so a step can hold anything, a form included. Next and Back add a transition type that slides the content that way; the progress and buttons sit outside it and stay clickable. useStepWizard gives you the step, next and back for your own controls.

Uses
composable parts, addTransitionType, vt-slide
Theme
--background, --foreground, --card, --card-foreground, --primary, --primary-foreground, --secondary, --secondary-foreground, --muted, --muted-foreground, --border, --input, --ring, --radius