Skip to content
  1. Navigation
  2. Stepper

Stepper

A flow cut into steps: the steps themselves, the buttons that move between them, and the hook that keeps the position. Steps already passed can be revisited, the ones ahead wait.

Where is your account located?

Select the country where the bank account is registered.

import { Stepper } from '@/components/stepper'
import { useWizard } from '@/components/use-wizard'
import { WizardActions } from '@/components/wizard-actions'
import { LockClosedIcon } from '@heroicons/react/16/solid'

const steps = [
  { key: 'country', label: 'Country' },
  { key: 'method', label: 'Method' },
  { key: 'details', label: 'Details' },
  { key: 'review', label: 'Review' },
]

export default function Example() {
  let wizard = useWizard(steps.length)

  return (
    <div>
      <Stepper steps={steps} value={wizard.step} reached={wizard.reached} onChange={wizard.goTo} />
      <div className="mt-8">
        {wizard.step === 0 && <CountryStep />}
        {wizard.step === 1 && <MethodStep />}
        {wizard.step === 2 && <DetailsStep />}
        {wizard.step === 3 && <ReviewStep />}
      </div>
      <WizardActions
        className="mt-10"
        step={wizard.step}
        count={steps.length}
        onPrevious={wizard.previous}
        onNext={wizard.step === steps.length - 1 ? save : wizard.next}
        submitIcon={<LockClosedIcon />}
      />
    </div>
  )
}
<script setup lang="ts">
import Stepper from '@/components/stepper.vue'
import { useWizard } from '@/components/use-wizard'
import WizardActions from '@/components/wizard-actions.vue'
import { LockClosedIcon } from '@heroicons/vue/16/solid'

const steps = [
  { key: 'country', label: 'Country' },
  { key: 'method', label: 'Method' },
  { key: 'details', label: 'Details' },
  { key: 'review', label: 'Review' },
]

const wizard = useWizard(steps.length)
</script>

<template>
  <div>
    <Stepper v-model="wizard.step.value" :steps="steps" :reached="wizard.reached.value" />
    <div class="mt-8">
      <CountryStep v-if="wizard.step.value === 0" />
      <MethodStep v-else-if="wizard.step.value === 1" />
      <DetailsStep v-else-if="wizard.step.value === 2" />
      <ReviewStep v-else />
    </div>
    <WizardActions
      class="mt-10"
      :step="wizard.step.value"
      :count="steps.length"
      @previous="wizard.previous"
      @next="wizard.isLast.value ? save() : wizard.next()"
    >
      <template #submit-icon><LockClosedIcon /></template>
    </WizardActions>
  </div>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import { CheckCircleIcon, CheckIcon, LockClosedIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import type React from 'react'

export type Step = { key: string; label: string }

const focus =
  'focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500'

export function Stepper({
  steps,
  value,
  reached = value,
  onChange,
  orientation = 'horizontal',
  className,
  ...props
}: {
  steps: Step[]
  value: number
  reached?: number
  onChange?: (index: number) => void
  orientation?: 'horizontal' | 'vertical'
} & Omit<React.ComponentPropsWithoutRef<'nav'>, 'onChange'>) {
  let state = (index: number) => (index === value ? 'current' : index <= reached ? 'done' : 'upcoming')

  if (orientation === 'vertical') {
    return (
      <nav aria-label="Steps" {...props} className={className}>
        <ol role="list" className="space-y-1">
          {steps.map((step, index) => (
            <li key={step.key}>
              <button
                type="button"
                disabled={state(index) === 'upcoming'}
                aria-current={index === value ? 'step' : undefined}
                onClick={() => onChange?.(index)}
                className={clsx(
                  'flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-base/6 font-medium sm:text-sm/6',
                  focus,
                  {
                    current: 'bg-zinc-950/5 text-zinc-950 dark:bg-white/5 dark:text-white',
                    done: 'cursor-pointer text-zinc-950 hover:bg-zinc-950/2.5 dark:text-white dark:hover:bg-white/2.5',
                    upcoming: 'text-zinc-400 dark:text-zinc-500',
                  }[state(index)]
                )}
              >
                {state(index) === 'done' ? (
                  <CheckCircleIcon className="size-4 shrink-0 text-zinc-950 dark:text-white" />
                ) : state(index) === 'current' ? (
                  <span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-zinc-950 text-[10px]/4 font-medium text-white dark:bg-white dark:text-zinc-950">
                    {index + 1}
                  </span>
                ) : (
                  <LockClosedIcon className="size-4 shrink-0 text-zinc-300 dark:text-zinc-600" />
                )}
                {step.label}
                {state(index) === 'done' && <span className="sr-only"> (completed)</span>}
              </button>
            </li>
          ))}
        </ol>
      </nav>
    )
  }

  return (
    <nav
      aria-label="Steps"
      {...props}
      className={clsx(className, '-mx-4 overflow-x-auto px-4 py-0.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden')}
    >
      <ol
        role="list"
        className="inline-flex w-max items-center gap-0.5 rounded-xl bg-zinc-950/5 p-1 ring-1 ring-zinc-200 dark:bg-zinc-950 dark:ring-white/10"
      >
        {steps.map((step, index) => (
          <li key={step.key}>
            <button
              type="button"
              disabled={state(index) === 'upcoming'}
              aria-current={index === value ? 'step' : undefined}
              onClick={() => onChange?.(index)}
              className={clsx(
                'flex items-center gap-2 rounded-lg py-1.5 pr-3.5 pl-2 text-base/6 font-medium whitespace-nowrap sm:py-1 sm:text-sm/6',
                focus,
                {
                  current: 'bg-white text-zinc-950 shadow-sm dark:bg-zinc-800 dark:text-white',
                  done: 'cursor-pointer text-zinc-950 hover:bg-white/60 dark:text-white dark:hover:bg-zinc-800/60',
                  upcoming: 'text-zinc-400 dark:text-zinc-500',
                }[state(index)]
              )}
            >
              <span
                className={clsx(
                  'flex size-5 shrink-0 items-center justify-center rounded-md text-xs/5 font-semibold',
                  {
                    current: 'bg-zinc-950/10 text-zinc-950 dark:bg-white/15 dark:text-white',
                    done: 'bg-zinc-950 text-white dark:bg-white dark:text-zinc-950',
                    upcoming: 'bg-zinc-950/5 text-zinc-400 dark:bg-white/10 dark:text-zinc-500',
                  }[state(index)]
                )}
              >
                {state(index) === 'done' ? <CheckIcon className="size-3.5" /> : index + 1}
              </span>
              {step.label}
              {state(index) === 'done' && <span className="sr-only"> (completed)</span>}
            </button>
          </li>
        ))}
      </ol>
    </nav>
  )
}

Also copy Button.

<script lang="ts">
export type Step = { key: string; label: string }
</script>

<script setup lang="ts">
import { CheckCircleIcon, CheckIcon, LockClosedIcon } from '@heroicons/vue/16/solid'

const focus =
  'focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500'

const props = withDefaults(
  defineProps<{
    steps: Step[]
    reached?: number
    orientation?: 'horizontal' | 'vertical'
  }>(),
  { reached: undefined, orientation: 'horizontal' }
)

const model = defineModel<number>({ required: true })

function state(index: number) {
  const reached = props.reached ?? model.value
  return index === model.value ? 'current' : index <= reached ? 'done' : 'upcoming'
}

const vertical = {
  button: {
    current: 'bg-zinc-950/5 text-zinc-950 dark:bg-white/5 dark:text-white',
    done: 'cursor-pointer text-zinc-950 hover:bg-zinc-950/2.5 dark:text-white dark:hover:bg-white/2.5',
    upcoming: 'text-zinc-400 dark:text-zinc-500',
  },
}

const horizontal = {
  button: {
    current: 'bg-white text-zinc-950 shadow-sm dark:bg-zinc-800 dark:text-white',
    done: 'cursor-pointer text-zinc-950 hover:bg-white/60 dark:text-white dark:hover:bg-zinc-800/60',
    upcoming: 'text-zinc-400 dark:text-zinc-500',
  },
  marker: {
    current: 'bg-zinc-950/10 text-zinc-950 dark:bg-white/15 dark:text-white',
    done: 'bg-zinc-950 text-white dark:bg-white dark:text-zinc-950',
    upcoming: 'bg-zinc-950/5 text-zinc-400 dark:bg-white/10 dark:text-zinc-500',
  },
}
</script>

<template>
  <nav v-if="orientation === 'vertical'" aria-label="Steps">
    <ol role="list" class="space-y-1">
      <li v-for="(step, index) in steps" :key="step.key">
        <button
          type="button"
          :disabled="state(index) === 'upcoming'"
          :aria-current="index === model ? 'step' : undefined"
          class="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-base/6 font-medium sm:text-sm/6"
          :class="[focus, vertical.button[state(index)]]"
          @click="model = index"
        >
          <CheckCircleIcon v-if="state(index) === 'done'" class="size-4 shrink-0 text-zinc-950 dark:text-white" />
          <span
            v-else-if="state(index) === 'current'"
            class="flex size-4 shrink-0 items-center justify-center rounded-full bg-zinc-950 text-[10px]/4 font-medium text-white dark:bg-white dark:text-zinc-950"
          >
            {{ index + 1 }}
          </span>
          <LockClosedIcon v-else class="size-4 shrink-0 text-zinc-300 dark:text-zinc-600" />
          {{ step.label }}
          <span v-if="state(index) === 'done'" class="sr-only"> (completed)</span>
        </button>
      </li>
    </ol>
  </nav>
  <nav v-else aria-label="Steps" class="-mx-4 overflow-x-auto px-4 py-0.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
    <ol
      role="list"
      class="inline-flex w-max items-center gap-0.5 rounded-xl bg-zinc-950/5 p-1 ring-1 ring-zinc-200 dark:bg-zinc-950 dark:ring-white/10"
    >
      <li v-for="(step, index) in steps" :key="step.key">
        <button
          type="button"
          :disabled="state(index) === 'upcoming'"
          :aria-current="index === model ? 'step' : undefined"
          class="flex items-center gap-2 rounded-lg py-1.5 pr-3.5 pl-2 text-base/6 font-medium whitespace-nowrap sm:py-1 sm:text-sm/6"
          :class="[focus, horizontal.button[state(index)]]"
          @click="model = index"
        >
          <span
            class="flex size-5 shrink-0 items-center justify-center rounded-md text-xs/5 font-semibold"
            :class="horizontal.marker[state(index)]"
          >
            <CheckIcon v-if="state(index) === 'done'" class="size-3.5" />
            <template v-else>{{ index + 1 }}</template>
          </span>
          {{ step.label }}
          <span v-if="state(index) === 'done'" class="sr-only"> (completed)</span>
        </button>
      </li>
    </ol>
  </nav>
</template>

Also copy Button.

Component API

PropDefaultDescription
Stepperrenders a <nav> element
steps-Array of `{ key, label }`, in order.
value-Index of the current step.
reachedvalueFurthest index reached. Steps up to it stay clickable, the ones after are locked.
onChange-Called with the index of a clicked step.
orientation'horizontal'`horizontal` is a tray of pills above the panel, `vertical` a list for a sidebar.
WizardActionsrenders a <div> element
step-Index of the current step.
count-Number of steps.
canContinuetrueWhether the current step is complete. Disables the forward button otherwise.
onPrevious-Called by the back button. Hidden on the first step.
onNext-Called by the forward button, on the last step too.
previousLabel'Previous'Label of the back button.
continueLabel'Continue'Label of the forward button before the last step.
submitLabel'Confirm and save'Label of the forward button on the last step.
submitIcon-An icon before that label: a lock before `Confirm and save`.
useWizard(count)
step-Index of the current step.
reached-Furthest index reached.
isFirst-Whether the current step is the first.
isLast-Whether it is the last.
next-Advances, and moves `reached` along.
previous-Goes back one.
goTo-Jumps to an index, if it was reached.
PropDefaultDescription
Stepperrenders a <nav> element
steps-Array of `{ key, label }`, in order.
v-model-Index of the current step.
reachedv-modelFurthest index reached. Steps up to it stay clickable, the ones after are locked.
orientation'horizontal'`horizontal` is a tray of pills above the panel, `vertical` a list for a sidebar.
WizardActionsrenders a <div> element
step-Index of the current step.
count-Number of steps.
canContinuetrueWhether the current step is complete. Disables the forward button otherwise.
@previous-Emitted by the back button. Hidden on the first step.
@next-Emitted by the forward button, on the last step too.
previousLabel'Previous'Label of the back button.
continueLabel'Continue'Label of the forward button before the last step.
submitLabel'Confirm and save'Label of the forward button on the last step.
#submit-icon-An icon before that label: a lock before `Confirm and save`.
useWizard(count)
step-Ref, index of the current step.
reached-Ref, furthest index reached.
isFirst-Whether the current step is the first.
isLast-Whether it is the last.
next-Advances, and moves `reached` along.
previous-Goes back one.
goTo-Jumps to an index, if it was reached.

Examples

Basic example

useWizard holds the position, Stepper shows it, WizardActions moves it. Render the panel of the current step between the two. Every panel opens with the same header, the question as a title and one line of help:

Where is your account located?

Select the country where the bank account is registered.

<h2 className="text-lg/7 font-semibold text-zinc-950 sm:text-base/7 dark:text-white">
  Where is your account located?
</h2>
<p className="mt-1 max-w-[60ch] text-base/7 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
  Select the country where the bank account is registered.
</p>
<h2 class="text-lg/7 font-semibold text-zinc-950 sm:text-base/7 dark:text-white">
  Where is your account located?
</h2>
<p class="mt-1 max-w-[60ch] text-base/7 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
  Select the country where the bank account is registered.
</p>

Vertical, with progress

Beside a long form, the steps go down a sidebar with a Meter above them. Locked steps show a lock, passed ones a check. The progress counts the steps done, so the last step still reads short of complete:

Where is your account located?

Select the country where the bank account is registered.

let progress = Math.round((wizard.step / steps.length) * 100)

<div className="lg:grid lg:grid-cols-[17rem_minmax(0,1fr)] lg:gap-x-12">
  <aside aria-label="Progress" className="max-lg:mb-8">
    <p className="text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white">Your progress</p>
    <div className="mt-1 flex items-baseline justify-between gap-4">
      <Text>{progress}% complete</Text>
      <Text>{wizard.step + 1}/{steps.length}</Text>
    </div>
    <Meter className="mt-2" value={progress} animated label="Progress" />
    <Stepper className="mt-6" orientation="vertical" steps={steps} value={wizard.step} reached={wizard.reached} onChange={wizard.goTo} />
  </aside>
  <div className="min-w-0">
    <StepPanel />
    <WizardActions className="mt-10" step={wizard.step} count={steps.length} onPrevious={wizard.previous} onNext={wizard.next} submitLabel="Submit for review" />
  </div>
</div>
const progress = computed(() => Math.round((wizard.step.value / steps.length) * 100))

<div class="lg:grid lg:grid-cols-[17rem_minmax(0,1fr)] lg:gap-x-12">
  <aside aria-label="Progress" class="max-lg:mb-8">
    <p class="text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white">Your progress</p>
    <div class="mt-1 flex items-baseline justify-between gap-4">
      <Text>{{ progress }}% complete</Text>
      <Text>{{ wizard.step.value + 1 }}/{{ steps.length }}</Text>
    </div>
    <Meter class="mt-2" :value="progress" animated label="Progress" />
    <Stepper v-model="wizard.step.value" class="mt-6" orientation="vertical" :steps="steps" :reached="wizard.reached.value" />
  </aside>
  <div class="min-w-0">
    <StepPanel />
    <WizardActions class="mt-10" :step="wizard.step.value" :count="steps.length" submit-label="Submit for review" @previous="wizard.previous" @next="wizard.next" />
  </div>
</div>

Gating the next step

Pass canContinue from the state of the current step. The forward button waits, the steps already reached stay clickable:

Where is your account located?

Select the country where the bank account is registered.

<WizardActions
  step={wizard.step}
  count={steps.length}
  canContinue={wizard.step !== 0 || confirmed}
  onPrevious={wizard.previous}
  onNext={wizard.next}
/>
<WizardActions
  :step="wizard.step.value"
  :count="steps.length"
  :can-continue="wizard.step.value !== 0 || confirmed"
  @previous="wizard.previous"
  @next="wizard.next"
/>