All patterns

Wizard Step Advance

The rail fills toward the step you are entering while the panel travels the way you sent it.

navigationcalmpremiuminteraction · finite · intermediate · ~0.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

427 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Wizard Step Advance
 *
 * Two things move on every advance and they agree with each other: the
 * rail fills toward the step you are entering, and the panel travels in
 * the direction you asked for. Going back reverses both.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the card reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `defaultStep`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type WizardStepAdvanceProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Step shown on first render, zero-based. */
  defaultStep?: number;
  /** Accent for completed markers and the filled rail. */
  accent?: string;
  /** Fires with the new step index. */
  onStepChange?: (step: number) => void;
};

type VariantConfig = {
  /** How far a panel travels as it enters or leaves, in px. */
  travel: number;
  /** Seconds for the outgoing panel to clear. */
  exit: number;
  /** Seconds for a rail segment to fill. */
  rail: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: panels are text, so they translate and fade and never
// scale, and their springs sit at or above a 0.8 damping ratio — a form
// field that overshoots its resting line and comes back is unreadable
// while it does it. The one thing that scales is the completion mark,
// which is a glyph. Variants change the distance and the pace of the
// rail, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A small shift. For a long internal flow that is stepped through
  // quickly.
  subtle: {
    travel: 12,
    exit: 0.1,
    rail: 0.22,
    spring: { type: "spring", stiffness: 620, damping: 46 },
  },
  // Enough travel to read the direction of the move. All-purpose.
  default: {
    travel: 22,
    exit: 0.14,
    rail: 0.32,
    spring: { type: "spring", stiffness: 470, damping: 38 },
  },
  // Longer travel and a slower fill, so each step lands as an event —
  // for a checkout or an account setup.
  playful: {
    travel: 34,
    exit: 0.17,
    rail: 0.42,
    spring: { type: "spring", stiffness: 380, damping: 33 },
  },
};

const ACCENT = "#7C7CF0";

/** The completion mark pops once and stops. Its own spring, because it is
 *  a glyph and can carry a little more life than the panels can. */
const MARK_SPRING = { type: "spring" as const, stiffness: 520, damping: 38 };

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or track that is
 *  correctly toned in either theme. The accent stays literal. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

type Panel = { heading: string; body: string; fields: string[] };
type Step = Panel & { marker: string };

const STEPS: Step[] = [
  {
    marker: "Details",
    heading: "Company details",
    body: "Legal name, registration number and the address invoices are issued from.",
    fields: ["Northwind Trading Ltd", "Registration 04812277"],
  },
  {
    marker: "Billing",
    heading: "Billing contact",
    body: "Where invoices are sent and who to chase when one is overdue.",
    fields: ["billing@northwind.example", "Net 30 terms"],
  },
  {
    marker: "Review",
    heading: "Review and confirm",
    body: "Nothing is charged until this is confirmed. Everything here can be edited later.",
    fields: ["Team plan · 24 seats", "$1,240.00 per month"],
  },
];

const DONE_PANEL: Panel = {
  heading: "Billing is set up",
  body: "The first invoice will be issued on the first of next month.",
  fields: ["Team plan · 24 seats", "Next invoice 1 October"],
};

export default function WizardStepAdvance({
  variant = "default",
  defaultStep = 0,
  accent = ACCENT,
  onStepChange,
}: WizardStepAdvanceProps) {
  const [step, setStep] = useState(defaultStep);
  const [direction, setDirection] = useState(1);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const finished = step >= STEPS.length;
  const panel = finished ? DONE_PANEL : STEPS[step];

  const goTo = (next: number) => {
    if (next < 0 || next > STEPS.length) return;
    setDirection(next > step ? 1 : -1);
    setStep(next);
    onStepChange?.(next);
  };

  // Reduced motion: the rail still fills and the panel still changes —
  // the panel simply cross-fades instead of travelling.
  const travel = reduceMotion ? 0 : cfg.travel;
  const panelVariants = {
    enter: (dir: number) => ({ x: dir * travel, opacity: 0 }),
    center: {
      x: 0,
      opacity: 1,
      transition: reduceMotion
        ? { duration: 0.14, ease: "easeOut" as const }
        : { ...cfg.spring, opacity: { duration: 0.18, ease: "easeOut" as const } },
    },
    exit: (dir: number) => ({
      x: -dir * travel,
      opacity: 0,
      transition: { duration: reduceMotion ? 0.1 : cfg.exit, ease: "easeIn" as const },
    }),
  };

  const railTransition = { duration: reduceMotion ? 0 : cfg.rail, ease: "easeOut" as const };
  const markTransition = reduceMotion ? { duration: 0 } : MARK_SPRING;

  return (
    <div
      style={{
        width: 336,
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 14px 34px rgba(0,0,0,0.18)",
        overflow: "hidden",
      }}
    >
      <div style={{ padding: "16px 18px 12px" }}>
        <div style={{ fontSize: 11, opacity: 0.5, letterSpacing: "0.05em" }}>
          {finished ? "COMPLETE" : `STEP ${step + 1} OF ${STEPS.length}`}
        </div>

        <div
          style={{
            display: "flex",
            alignItems: "center",
            marginTop: 12,
          }}
        >
          {STEPS.map((entry, index) => {
            const complete = index < step;
            const current = index === step;
            return (
              <div
                key={entry.marker}
                style={{
                  display: "flex",
                  alignItems: "center",
                  flex: index === STEPS.length - 1 ? "0 0 auto" : 1,
                }}
              >
                <div
                  style={{
                    display: "flex",
                    flexDirection: "column",
                    alignItems: "center",
                    gap: 5,
                    width: 52,
                  }}
                >
                  <span
                    aria-hidden
                    style={{
                      position: "relative",
                      display: "grid",
                      placeItems: "center",
                      width: 24,
                      height: 24,
                      borderRadius: 12,
                      background: tone(8),
                      border: `1px solid ${tone(14)}`,
                    }}
                  >
                    {/* The ring marks "you are here". It fades in place
                        rather than travelling from the previous step: the
                        panel is already moving, and two moving objects
                        would split the glance. */}
                    <motion.span
                      initial={false}
                      animate={{ opacity: current ? 1 : 0 }}
                      transition={{ duration: reduceMotion ? 0 : 0.18, ease: "easeOut" }}
                      style={{
                        position: "absolute",
                        inset: -2,
                        borderRadius: 14,
                        border: `2px solid ${accent}`,
                      }}
                    />
                    <motion.span
                      initial={false}
                      animate={{ opacity: complete ? 1 : 0, scale: complete ? 1 : 0.7 }}
                      transition={markTransition}
                      style={{
                        position: "absolute",
                        inset: -1,
                        borderRadius: 13,
                        background: accent,
                      }}
                    />
                    {/* The numeral only fades. It is text, and text that
                        changes size on a step change is the tell. */}
                    <motion.span
                      initial={false}
                      animate={{ opacity: complete ? 0 : 1 }}
                      transition={{ duration: reduceMotion ? 0 : 0.14, ease: "easeOut" }}
                      style={{
                        position: "relative",
                        fontSize: 10.5,
                        fontWeight: 700,
                        fontVariantNumeric: "tabular-nums",
                        opacity: current ? 1 : 0.55,
                      }}
                    >
                      {index + 1}
                    </motion.span>
                    <motion.svg
                      width="12"
                      height="12"
                      viewBox="0 0 14 14"
                      fill="none"
                      stroke="#ffffff"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      initial={false}
                      animate={{ opacity: complete ? 1 : 0, scale: complete ? 1 : 0.6 }}
                      transition={markTransition}
                      style={{ position: "absolute" }}
                    >
                      <path d="m3.4 7.3 2.4 2.5 4.8-5.1" />
                    </motion.svg>
                  </span>
                  <span
                    style={{
                      fontSize: 10,
                      fontWeight: 600,
                      opacity: current || complete ? 0.85 : 0.42,
                    }}
                  >
                    {entry.marker}
                  </span>
                </div>

                {index < STEPS.length - 1 && (
                  <div
                    aria-hidden
                    style={{
                      flex: 1,
                      height: 2,
                      marginBottom: 18,
                      borderRadius: 1,
                      background: tone(12),
                      overflow: "hidden",
                    }}
                  >
                    {/* scaleX from the left: the segment fills toward the
                        step being entered, which is the same direction the
                        panel travels. */}
                    <motion.div
                      initial={false}
                      animate={{ scaleX: index < step ? 1 : 0 }}
                      transition={railTransition}
                      style={{
                        height: "100%",
                        background: accent,
                        transformOrigin: "0% 50%",
                      }}
                    />
                  </div>
                )}
              </div>
            );
          })}
        </div>
      </div>

      {/* Fixed height: a panel that resizes mid-move fights its own
          horizontal travel and makes the buttons below hop. */}
      <div
        style={{
          position: "relative",
          height: 132,
          borderTop: `1px solid ${tone(10)}`,
          overflow: "hidden",
        }}
      >
        <AnimatePresence mode="wait" custom={direction} initial={false}>
          <motion.div
            key={finished ? "done" : step}
            custom={direction}
            variants={panelVariants}
            initial="enter"
            animate="center"
            exit="exit"
            style={{
              position: "absolute",
              inset: 0,
              padding: "14px 18px",
            }}
          >
            <div style={{ fontSize: 14.5, fontWeight: 650 }}>{panel.heading}</div>
            <p
              style={{
                margin: "5px 0 10px",
                fontSize: 12,
                lineHeight: 1.5,
                opacity: 0.6,
              }}
            >
              {panel.body}
            </p>
            {panel.fields.map((field) => (
              <div
                key={field}
                style={{
                  display: "flex",
                  alignItems: "center",
                  gap: 8,
                  padding: "7px 10px",
                  marginBottom: 6,
                  borderRadius: 9,
                  background: tone(7),
                  border: `1px solid ${tone(10)}`,
                  fontSize: 12,
                }}
              >
                {field}
              </div>
            ))}
          </motion.div>
        </AnimatePresence>
      </div>

      <div
        style={{
          display: "flex",
          gap: 8,
          padding: "12px 18px 16px",
          borderTop: `1px solid ${tone(10)}`,
        }}
      >
        <button
          type="button"
          onClick={() => goTo(step - 1)}
          disabled={step === 0}
          style={{
            padding: "9px 14px",
            fontSize: 12.5,
            fontWeight: 600,
            fontFamily: "inherit",
            borderRadius: 10,
            border: `1px solid ${tone(14)}`,
            background: "transparent",
            color: "inherit",
            opacity: step === 0 ? 0.35 : 1,
            cursor: step === 0 ? "default" : "pointer",
          }}
        >
          Back
        </button>
        <button
          type="button"
          onClick={() => goTo(finished ? 0 : step + 1)}
          style={{
            flex: 1,
            padding: "9px 14px",
            fontSize: 12.5,
            fontWeight: 600,
            fontFamily: "inherit",
            borderRadius: 10,
            border: 0,
            background: accent,
            color: "#ffffff",
            cursor: "pointer",
          }}
        >
          {finished
            ? "Start over"
            : step === STEPS.length - 1
              ? "Confirm and finish"
              : "Continue"}
        </button>
      </div>
    </div>
  );
}

About this pattern

A multi-part flow where the marker and the content are saying the same thing at the same time: the rail segment fills from the left toward the step being entered, the marker behind it takes its completion mark, and the panel travels in that same direction — reversed exactly when you go back. Agreement is the whole point, because a header that advances while the body slides the other way is how a flow starts feeling arbitrary. The panels are text, so they translate and fade and never scale, on springs damped hard enough that no field overshoots its resting line. The one thing that pops is the completion mark, which is a glyph and can carry a little more life than a paragraph can. The body is a fixed height so the buttons underneath never hop.

Account setup flowBilling configurationCheckout stagesGuided import

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Payment
    Card number4242 4242 4242 4242Name on cardN. Bergström
    Expiry04 / 28CVC•••
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Pay $169.65
    Checkout

    A row of stages that fills as the buyer moves through information, shipping and payment.

Related patterns