Step Wizard

Steps slide the way you move, forward or back.

1 / 3

Invite your team

Bring collaborators in. Everyone gets their own workspace view.

"use client"

import { addTransitionType, startTransition, 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>
)
const Check = ({ className }: { className?: string }) => (
  <svg {...icon} className={className}>
    <path d="M3.5 8.5l3 3 6-7" />
  </svg>
)

export type Step = { icon?: ReactNode; title: string; body: string }
type Progress = { step: number; done: boolean }

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({
  steps,
  doneTitle = "You're all set",
  doneBody = "Your workspace is ready to go.",
}: {
  steps: Step[]
  doneTitle?: string
  doneBody?: string
}) {
  const [{ step, done }, setState] = useState<Progress>({
    step: 0,
    done: false,
  })
  const last = steps.length - 1

  const move = (direction: "next" | "prev", update: (s: Progress) => Progress) => {
    document.activeViewTransition?.skipTransition()
    startTransition(() => {
      addTransitionType(`wizard-${direction}`)
      setState(update)
    })
  }

  const next = () =>
    move("next", (s) =>
      s.step === last ? { ...s, done: true } : { step: s.step + 1, done: false },
    )
  const prev = () =>
    move("prev", (s) =>
      s.done ? { ...s, done: false } : { step: Math.max(0, s.step - 1), done: false },
    )
  const restart = () => move("prev", () => ({ step: 0, done: false }))

  const current = steps[step]

  return (
    <div className="w-full max-w-sm rounded-2xl border border-border bg-card p-5 text-card-foreground">
      <div className="mb-5 flex items-center gap-1.5">
        {steps.map((_, 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 ? "Done" : `${step + 1} / ${steps.length}`}
        </span>
      </div>

      <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" aria-live="polite">
          <div className="mb-3 grid size-10 place-items-center rounded-xl bg-muted [&_svg]:size-5">
            {done ? <Check className="text-foreground" /> : current?.icon}
          </div>
          <h3 className="font-semibold">{done ? doneTitle : current?.title}</h3>
          <p className="mt-1 text-sm text-muted-foreground">{done ? doneBody : current?.body}</p>
        </div>
      </ViewTransition>

      <div className="mt-5 flex justify-between">
        <button
          type="button"
          onClick={prev}
          disabled={step === 0 && !done}
          className={`${button} bg-secondary text-secondary-foreground hover:bg-secondary/80`}
        >
          <ArrowLeft /> Back
        </button>
        {done ? (
          <button type="button" onClick={restart} className={`${button} ${outline}`}>
            Start over
          </button>
        ) : (
          <button
            type="button"
            onClick={next}
            className={`${button} bg-primary text-primary-foreground hover:bg-primary/80`}
          >
            {step === last ? "Finish" : "Continue"} <ArrowRight />
          </button>
        )}
      </div>
    </div>
  )
}

Installation

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

How it works

Next and Back select directional CSS through addTransitionType. Only the step content is captured, leaving the progress indicator and buttons interactive.

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