Skip to content

Wizard steps whose numbers draw into ticks as the rail fills.

01Preview

  1. Step 2 of 4: Workspace, current step
  2. Step 3 of 4: Billing
  3. Step 4 of 4: Invite teamOptional

Workspace

Your team will find the workspace at northwind.stealth.app.

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { Stepper, StepperStep } from "@/components/ui/stepper";

<Stepper value={step} onValueChange={setStep}>
  <StepperStep title="Account" />
  <StepperStep title="Workspace" />
  <StepperStep title="Billing" loading={saving} error={error} />
  <StepperStep title="Invite team" optional />
</Stepper>

// Vertical, with each step's content opening under it.
<Stepper value={step} onValueChange={setStep} orientation="vertical">
  <StepperStep title="Connect repository" description="github.com/northwind/api">
    <RepoPicker />
  </StepperStep>
  <StepperStep title="Configure build" />
</Stepper>

04Source

"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Children, createContext, isValidElement, use, useCallback, useMemo, useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

/* -------------------------------------------------------------------------------------------------
 * The step logic on its own
 * -----------------------------------------------------------------------------------------------*/

/** Where a wizard is and how to move it. `value === count` means every step is done. */
export function useStepper({ count, defaultValue = 0 }: { count: number; defaultValue?: number }) {
  const [value, setValue] = useState(defaultValue);
  const goTo = useCallback((i: number) => setValue(Math.min(Math.max(0, i), count)), [count]);
  return {
    value,
    setValue: goTo,
    next: () => goTo(value + 1),
    back: () => goTo(value - 1),
    reset: () => goTo(0),
    isFirst: value === 0,
    isLast: value === count - 1,
    isComplete: value >= count,
  };
}

/* -------------------------------------------------------------------------------------------------
 * Stepper
 * -----------------------------------------------------------------------------------------------*/

type Orientation = "horizontal" | "vertical";
export type StepStatus = "complete" | "current" | "upcoming" | "error" | "loading";

type Ctx = {
  value: number;
  count: number;
  orientation: Orientation;
  linear: boolean;
  instant: boolean;
  go: (index: number, e: React.MouseEvent) => void;
};
const StepperContext = createContext<Ctx | null>(null);
const IndexContext = createContext(0);

export type StepperProps = Omit<React.ComponentProps<"ol">, "onChange"> & {
  /** The current step, from 0. Equal to the number of steps when everything is done. */
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  orientation?: Orientation;
  /** Linear flows only let people go back to completed steps. Set false to allow jumping ahead. */
  linear?: boolean;
};

export function Stepper({ value: valueProp, defaultValue = 0, onValueChange, orientation = "horizontal", linear = true, className, children, ...rest }: StepperProps) {
  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });
  // A step chosen with the keyboard renders on the same frame; a click animates.
  const [instant, setInstant] = useState(false);
  const steps = Children.toArray(children).filter(isValidElement<StepperStepProps>);
  const current = value >= steps.length ? "All steps complete" : steps[value]?.props.title;

  const ctx = useMemo<Ctx>(
    () => ({
      value,
      count: steps.length,
      orientation,
      linear,
      instant,
      go: (index, e) => {
        setInstant(e.detail === 0);
        setValue(index);
      },
    }),
    [value, steps.length, orientation, linear, instant, setValue],
  );

  return (
    <StepperContext value={ctx}>
      <ol
        aria-label={rest["aria-label"] ?? "Progress"}
        data-orientation={orientation}
        className={cn(
          orientation === "horizontal" ? "@container flex w-full flex-wrap items-start" : "flex flex-col",
          className,
        )}
        {...rest}
      >
        {steps.map((step, i) => (
          <IndexContext key={step.key ?? i} value={i}>
            {step}
          </IndexContext>
        ))}
        {orientation === "horizontal" && (
          // In a narrow container the labels step aside for one line about where you are.
          <li aria-hidden className="mt-3 hidden min-w-0 basis-full truncate text-[12.5px] @max-[30rem]:block">
            <span className="font-medium text-fg">{current}</span>
            <span className="text-fg-3"> · Step {Math.min(value + 1, steps.length)} of {steps.length}</span>
          </li>
        )}
      </ol>
    </StepperContext>
  );
}

/* -------------------------------------------------------------------------------------------------
 * A step
 * -----------------------------------------------------------------------------------------------*/

export type StepperStepProps = Omit<React.ComponentProps<"li">, "title"> & {
  title: React.ReactNode;
  description?: React.ReactNode;
  /** Marks the step as failed and shows the message in place of the description. */
  error?: React.ReactNode;
  /** Shows a spinner on the step, for while it validates or saves. */
  loading?: boolean;
  /** Adds a quiet "Optional" label. */
  optional?: boolean;
  disabled?: boolean;
  /** Vertical only: content shown under the current step. */
  children?: React.ReactNode;
};

export function StepperStep({ title, description, error, loading = false, optional = false, disabled = false, className, children, ...rest }: StepperStepProps) {
  const ctx = use(StepperContext);
  const index = use(IndexContext);
  if (!ctx) throw new Error("StepperStep must be inside a Stepper");
  const { value, count, orientation, linear, instant, go } = ctx;

  const status: StepStatus = error ? "error" : index < value ? "complete" : index === value ? (loading ? "loading" : "current") : "upcoming";
  const last = index === count - 1;
  const done = index < value;
  const clickable = !disabled && index !== value && (index < value || !linear);
  const vertical = orientation === "vertical";

  const label = (
    <>
      <Indicator status={status} index={index} instant={instant} />
      <span className={cn("flex min-w-0 max-w-full flex-col text-left", vertical ? "pt-[3px]" : "@max-[30rem]:sr-only")}>
        <span className="flex items-baseline gap-1.5">
          <span
            className={cn(
              "truncate text-[13px] font-medium tracking-[-0.01em] transition-colors duration-200",
              status === "error" ? "text-danger" : status === "upcoming" ? "text-fg-3" : "text-fg",
              clickable && "group-hover/step:text-fg",
            )}
          >
            <span className="sr-only">
              Step {index + 1} of {count}:{" "}
            </span>
            {title}
          </span>
          {optional && <span className="shrink-0 text-[11px] text-fg-4">Optional</span>}
        </span>
        {(error || description) && (
          <span className={cn("text-[12px] leading-4", error ? "text-danger/90" : "text-fg-3", !vertical && "truncate")}>{error || description}</span>
        )}
        <span className="sr-only">{statusText[status]}</span>
      </span>
    </>
  );

  const labelClass = cn(
    "group/step relative flex min-w-0 items-start rounded-lg text-left outline-none",
    vertical ? "gap-2.5" : "max-w-full flex-col gap-2",
    "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-4 focus-visible:outline-fg-3",
    disabled && "opacity-50",
  );

  return (
    <li
      aria-current={index === value ? "step" : undefined}
      data-status={status}
      data-disabled={disabled || undefined}
      className={cn(
        // Horizontal steps share the row equally; the last one only takes what its label needs, so the rail ends at its circle.
        vertical ? "grid grid-cols-[24px_minmax(0,1fr)] gap-x-3" : cn("relative flex min-w-0 items-start", last ? "flex-none" : "flex-1 pr-4"),
        className,
      )}
      {...rest}
    >
      {vertical ? (
        <>
          <div className="col-start-1 row-start-1 flex flex-col items-center">
            {/* The label sits in the second column; its button spans both so the circle is part of the target. */}
            <Connector filled={done} hidden={last} vertical instant={instant} />
          </div>
          <div className="col-start-1 row-start-1 col-span-2 flex min-w-0 flex-col">
            {clickable ? (
              <button type="button" onClick={(e) => go(index, e)} className={cn(labelClass, "transition-transform duration-150 ease-out active:scale-[0.98]")}>
                {label}
              </button>
            ) : (
              <div className={labelClass}>{label}</div>
            )}
            <Panel open={index === value && !!children} instant={instant}>
              {children}
            </Panel>
            {!last && <div className="h-5" />}
          </div>
        </>
      ) : (
        <>
          {clickable ? (
            <button type="button" onClick={(e) => go(index, e)} className={cn(labelClass, "transition-transform duration-150 ease-out active:scale-[0.97]")}>
              {label}
            </button>
          ) : (
            <div className={labelClass}>{label}</div>
          )}
          {/* Runs from this circle to the next one, under the labels' row. */}
          {!last && <Connector filled={done} instant={instant} />}
        </>
      )}
    </li>
  );
}

const statusText: Record<StepStatus, string> = {
  complete: ", completed",
  current: ", current step",
  upcoming: "",
  error: ", needs attention",
  loading: ", saving",
};

/* -------------------------------------------------------------------------------------------------
 * Parts
 * -----------------------------------------------------------------------------------------------*/

// The number becomes a drawn tick when the step completes, an exclamation when it fails,
// and gains a turning arc while it saves. The circle's fill and ring change in CSS.
function Indicator({ status, index, instant }: { status: StepStatus; index: number; instant: boolean }) {
  const reduce = useReducedMotion();
  const glyph = status === "complete" ? "check" : status === "error" ? "error" : "number";
  const swap = reduce || instant ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } : { initial: { opacity: 0, scale: 0.5, filter: "blur(2px)" }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: { opacity: 0, scale: 0.5, filter: "blur(2px)" } };

  return (
    <span
      aria-hidden
      data-status={status}
      className={cn(
        "relative grid size-6 shrink-0 place-items-center rounded-full border text-[11.5px] font-medium tabular",
        "transition-[background-color,border-color,color,box-shadow,scale] duration-200 ease-out motion-reduce:transition-none",
        "group-hover/step:border-fg-3 group-active/step:scale-[0.94]",
        status === "upcoming" && "border-line-2 bg-raised text-fg-3",
        (status === "current" || status === "loading") && "border-fg bg-raised text-fg ring-4 ring-fg/[0.07]",
        status === "complete" && "border-fg bg-fg text-frame group-hover/step:border-fg group-hover/step:bg-fg/85",
        status === "error" && "border-danger/60 bg-danger-soft text-danger ring-4 ring-danger/[0.08] group-hover/step:border-danger",
        instant && "duration-0",
      )}
    >
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span key={glyph} className="absolute inset-0 grid place-items-center" {...swap} transition={instant ? { duration: 0 } : reduce ? { duration: 0.15 } : spring.pop}>
          {glyph === "check" ? (
            <svg width={12} height={12} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
              <motion.path
                d="M3.5 8.5 6.5 11.5 12.5 4.5"
                initial={reduce || instant ? false : { pathLength: 0 }}
                animate={{ pathLength: 1 }}
                transition={{ duration: 0.3, ease: ease.out, delay: 0.06 }}
              />
            </svg>
          ) : glyph === "error" ? (
            <svg width={12} height={12} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
              <path d="M8 3.75v5" />
              <circle cx="8" cy="12" r=".6" fill="currentColor" />
            </svg>
          ) : (
            index + 1
          )}
        </motion.span>
      </AnimatePresence>
      {status === "loading" && (
        <svg className="absolute -inset-[3px] size-[calc(100%+6px)] animate-spin-slow text-fg motion-reduce:animate-none" viewBox="0 0 30 30" fill="none">
          <circle cx="15" cy="15" r="14" stroke="currentColor" strokeOpacity={0.12} strokeWidth={1.5} />
          <path d="M15 1a14 14 0 0 1 14 14" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
        </svg>
      )}
    </span>
  );
}

// A hairline track with a fill that runs toward the next step. Advancing fills after the tick
// has started drawing; going back empties at once, so the rail never lags the click.
function Connector({ filled, vertical = false, hidden = false, instant }: { filled: boolean; vertical?: boolean; hidden?: boolean; instant: boolean }) {
  const reduce = useReducedMotion();
  if (hidden) return null;
  return (
    <span
      aria-hidden
      className={cn(
        "overflow-hidden rounded-full bg-line-2",
        vertical ? "relative mt-[30px] mb-1.5 w-px flex-1" : "pointer-events-none absolute top-[11.5px] right-2 left-8 h-px",
      )}
    >
      <motion.span
        className={cn("absolute inset-0 bg-fg", vertical ? "origin-top" : "origin-left")}
        initial={false}
        animate={vertical ? { scaleY: filled ? 1 : 0 } : { scaleX: filled ? 1 : 0 }}
        transition={instant || reduce ? { duration: instant ? 0 : 0.15 } : { duration: filled ? 0.36 : 0.2, ease: ease.inOut, delay: filled ? 0.08 : 0 }}
      />
    </span>
  );
}

// Vertical steps open their content under the title; the height eases so the steps below
// move instead of jumping, and the closed panel is inert so its fields leave the tab order.
function Panel({ open, instant, children }: { open: boolean; instant: boolean; children?: React.ReactNode }) {
  return (
    <div
      inert={!open}
      className={cn(
        "grid transition-[grid-template-rows,opacity] duration-260 ease-in-out-quart motion-reduce:transition-none",
        open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
        instant && "duration-0",
      )}
    >
      <div className="min-h-0 overflow-hidden pl-[34px]">{children && <div className="pt-3">{children}</div>}</div>
    </div>
  );
}

05Props

Stepper

PropTypeDefaultDescription
valuenumberThe current step, from 0. Set it to the number of steps to show everything complete.
defaultValuenumber0Starting step when uncontrolled.
onValueChange(value: number) => voidCalled when someone picks a step from the rail.
orientation"horizontal" | "vertical""horizontal"A row of steps with labels underneath, or a column with content under the current step.
linearbooleantrueOnly completed steps can be revisited. Set false to let people jump ahead.

StepperStep

PropTypeDefaultDescription
title*ReactNodeThe step's name.
descriptionReactNodeA quieter second line: the answer given, or what the step asks for.
errorReactNodeMarks the step as failed and shows the message in place of the description.
loadingbooleanfalseA turning arc around the step while it validates or saves.
optionalbooleanfalseAdds a quiet Optional label.
disabledbooleanfalseThe step can't be chosen from the rail.
childrenReactNodeVertical only: content that opens under the step while it's current.

useStepper

PropTypeDefaultDescription
count*numberNumber of steps. Returns { value, setValue, next, back, reset, isFirst, isLast, isComplete }.
defaultValuenumber0Starting step.

06Notes

Behavior

  • Completed steps are buttons that take you back; the current and upcoming steps are plain text unless linear is off, so a rail never offers a jump the flow can't honor.
  • Horizontal steps share the width equally and truncate long titles; below 480px of container width the labels step aside for a single “Workspace · Step 2 of 4” line under the rail.
  • An error keeps the rail filled up to the failed step and replaces its description with the message; clearing the error returns it to current.
  • In vertical mode the closed panels are inert, so fields in steps you aren't on stay out of the tab order.

Motion

  • On completion the number pops out and a tick draws its path in 300ms on the expo ease-out, while the connector fills toward the next step over 360ms on ease-in-out, 80ms behind.
  • Going back empties the connector in 200ms with no delay, so the rail never lags the click. Vertical panels open by grid rows over 260ms so the steps below move rather than jump.
  • Steps chosen from the keyboard change on the same frame. Reduced motion keeps 150ms fades and drops the pop, the draw and the fill.

Accessibility

  • An ordered list labeled Progress; the current step has aria-current="step" and every step reads as “Step 2 of 4: Workspace, completed” or “needs attention”.
  • Revisitable steps are native buttons in the tab order with a visible focus ring; the rest are not focusable.
  • The circle and its glyphs are decorative; status is carried in text, never color alone.