All patterns

Checkout Step Progress

The finished stage folds into a one-line recap while the following one opens and the rail fills.

commercepremiumcalminteraction · finite · intermediate · ~0.6s
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.

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

/**
 * Vibary · Checkout Step Progress
 *
 * Checkout advances by folding what is settled out of the way: the step
 * you just finished collapses into a one-line summary, the following one
 * opens in the space it left, and the rail beside them fills to where
 * you are.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the flow reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `steps`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CheckoutStep = {
  id: string;
  /** Heading of the step. */
  label: string;
  /** One-line recap once the step is behind you. */
  summary: string;
  /** Placeholder text for the two sample fields. */
  fields: string[];
  /** Label of the control that closes this step. */
  action: string;
};

export type CheckoutStepProgressProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The stages of the flow, in order. */
  steps?: CheckoutStep[];
  /** Accent for the rail, the dots and the primary control. */
  accent?: string;
  /** Line shown once every stage is behind you. */
  completeLabel?: string;
  /** Fires with the index of each stage as it closes. */
  onAdvance?: (index: number) => void;
};

type VariantConfig = {
  /** How long a body takes to fold away or open. */
  foldSeconds: number;
  /** Crossfade for the summary line replacing the body. */
  swapSeconds: number;
  /** Spring the rail fill rides to the current stage. */
  rail: { type: "spring"; stiffness: number; damping: number };
};

// A flow that overshoots looks unsure of where it is. Rail damping
// ratios (damping / 2√stiffness) stay at or above 0.9, and the folds are
// plain eased tweens — a height change that springs reads as rubber.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Brisk. For a returning customer who has done this before.
  subtle: {
    foldSeconds: 0.22,
    swapSeconds: 0.14,
    rail: { type: "spring", stiffness: 520, damping: 46 },
  },
  // Enough time to see what folded and what opened. All-purpose.
  default: {
    foldSeconds: 0.32,
    swapSeconds: 0.2,
    rail: { type: "spring", stiffness: 380, damping: 36 },
  },
  // A longer fold for a first checkout where the structure is new.
  playful: {
    foldSeconds: 0.42,
    swapSeconds: 0.24,
    rail: { type: "spring", stiffness: 280, damping: 32 },
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one.
 *  The accent stays literal. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_STEPS: CheckoutStep[] = [
  {
    id: "contact",
    label: "Contact",
    summary: "casey@northwind.co",
    fields: ["Email address", "Phone"],
    action: "Continue",
  },
  {
    id: "delivery",
    label: "Delivery",
    summary: "18 Rowan Street, Leeds",
    fields: ["Street address", "Postcode"],
    action: "Continue",
  },
  {
    id: "payment",
    label: "Payment",
    summary: "Visa ending 4242",
    fields: ["Card number", "Expiry and CVC"],
    action: "Pay $267.42",
  },
];

function CheckGlyph() {
  return (
    <svg width="10" height="10" viewBox="0 0 14 14" fill="none" aria-hidden>
      <path
        d="M2.8 7.4 5.6 10.2 11.2 4"
        stroke="#FFFFFF"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export default function CheckoutStepProgress({
  variant = "default",
  steps = DEFAULT_STEPS,
  accent = "#7C7CF0",
  completeLabel = "Paid in full · a receipt is on its way",
  onAdvance,
}: CheckoutStepProgressProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [current, setCurrent] = useState(0);
  const done = current >= steps.length;

  const advance = () => {
    if (current >= steps.length) return;
    onAdvance?.(current);
    setCurrent(current + 1);
  };

  return (
    <div
      style={{
        position: "relative",
        width: 282,
        display: "grid",
        gridTemplateColumns: "18px 1fr",
        columnGap: 12,
        fontSize: 13,
      }}
    >
      <div style={{ gridColumn: 2, display: "grid" }}>
        {steps.map((step, index) => {
          const state =
            index < current ? "done" : index === current ? "open" : "waiting";
          const last = index === steps.length - 1;
          return (
            <div
              key={step.id}
              style={{
                position: "relative",
                paddingBottom: last ? 0 : 14,
              }}
            >
              {/* Each stage carries its own length of rail down to the
                  one below, so the connector is always exactly as long as
                  the stage above it — no measuring, no drift when a body
                  folds. The fill is a scaleY transform: zero layout cost. */}
              {!last && (
                <span aria-hidden>
                  <span
                    style={{
                      position: "absolute",
                      left: -22,
                      top: 22,
                      bottom: 2,
                      width: 2,
                      borderRadius: 2,
                      background: tone(12),
                    }}
                  />
                  <motion.span
                    initial={false}
                    animate={{ scaleY: index < current ? 1 : 0 }}
                    transition={reduceMotion ? { duration: 0 } : cfg.rail}
                    style={{
                      position: "absolute",
                      left: -22,
                      top: 22,
                      bottom: 2,
                      width: 2,
                      borderRadius: 2,
                      background: accent,
                      transformOrigin: "50% 0%",
                    }}
                  />
                </span>
              )}

              {/* Dot lives in the rail column but is positioned from the
                  row, so it always lines up with its own heading. */}
              <span
                aria-hidden
                style={{
                  position: "absolute",
                  left: -30,
                  top: 3,
                  width: 18,
                  height: 18,
                  display: "grid",
                  placeItems: "center",
                  borderRadius: 999,
                  background: state === "waiting" ? tone(10) : accent,
                  border:
                    state === "open"
                      ? `3px solid color-mix(in srgb, ${accent} 30%, transparent)`
                      : "none",
                  boxSizing: "border-box",
                }}
              >
                {state === "done" ? <CheckGlyph /> : null}
              </span>

              <div
                style={{
                  display: "flex",
                  alignItems: "baseline",
                  gap: 8,
                  minHeight: 20,
                }}
              >
                <span
                  style={{
                    fontSize: 12.5,
                    fontWeight: 600,
                    opacity: state === "waiting" ? 0.45 : 1,
                  }}
                >
                  {step.label}
                </span>
                {/* The recap and the empty state share one cell, so the
                    swap can never nudge the heading beside them. */}
                <span
                  style={{
                    marginLeft: "auto",
                    display: "grid",
                    justifyItems: "end",
                    minWidth: 0,
                  }}
                >
                  <motion.span
                    initial={false}
                    animate={{ opacity: state === "done" ? 0.55 : 0 }}
                    transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
                    style={{
                      gridArea: "1 / 1",
                      fontSize: 11.5,
                      whiteSpace: "nowrap",
                      overflow: "hidden",
                      textOverflow: "ellipsis",
                    }}
                  >
                    {step.summary}
                  </motion.span>
                </span>
              </div>

              {/* The body is a genuine size change, so it tweens on height
                  — short and eased, with the contents fading a little
                  ahead of the fold so nothing is caught mid-clip. */}
              <motion.div
                initial={false}
                animate={{
                  height: state === "open" ? "auto" : 0,
                  opacity: state === "open" ? 1 : 0,
                }}
                transition={
                  reduceMotion
                    ? { duration: 0 }
                    : {
                        height: { duration: cfg.foldSeconds, ease: [0.3, 0, 0.2, 1] },
                        opacity: {
                          duration: cfg.swapSeconds,
                          ease: "easeOut",
                          delay: state === "open" ? cfg.foldSeconds * 0.4 : 0,
                        },
                      }
                }
                style={{ overflow: "hidden" }}
                aria-hidden={state !== "open"}
              >
                <div style={{ display: "grid", gap: 7, paddingTop: 8 }}>
                  {step.fields.map((field) => (
                    <input
                      key={field}
                      type="text"
                      placeholder={field}
                      tabIndex={state === "open" ? 0 : -1}
                      style={{
                        height: 32,
                        padding: "0 10px",
                        fontSize: 12.5,
                        fontFamily: "inherit",
                        color: "inherit",
                        background: tone(6),
                        border: `1px solid ${tone(12)}`,
                        borderRadius: 8,
                        outline: "none",
                        width: "100%",
                        boxSizing: "border-box",
                      }}
                    />
                  ))}
                  <button
                    type="button"
                    onClick={advance}
                    tabIndex={state === "open" ? 0 : -1}
                    style={{
                      marginTop: 2,
                      height: 34,
                      fontSize: 12.5,
                      fontWeight: 600,
                      fontFamily: "inherit",
                      color: "#FFFFFF",
                      background: accent,
                      border: 0,
                      borderRadius: 9,
                      cursor: "pointer",
                    }}
                  >
                    {step.action}
                  </button>
                </div>
              </motion.div>
            </div>
          );
        })}
      </div>

      <motion.div
        initial={false}
        animate={{ opacity: done ? 1 : 0, y: done || reduceMotion ? 0 : 4 }}
        transition={{
          duration: cfg.swapSeconds,
          ease: "easeOut",
          delay: done && !reduceMotion ? cfg.foldSeconds * 0.6 : 0,
        }}
        aria-live="polite"
        style={{
          gridColumn: "1 / -1",
          marginTop: 12,
          fontSize: 12,
          fontWeight: 600,
          color: accent,
        }}
      >
        {done ? completeLabel : ""}
      </motion.div>
    </div>
  );
}

About this pattern

Checkout gets shorter as you go. Closing a stage folds its fields away on a short eased height tween and leaves a single recap line in their place, the stage below opens into the room that freed up, and the connector beside them fills to mark what is behind you. Keeping exactly one stage open means the page never grows while it is being read, and the recap lines double as the edit affordance. Nothing springs on height — a form that bounces open reads as rubber, not as progress.

Multi-stage checkoutAddress and payment captureGuided purchase flowBooking flow with recap lines

Where it shows up

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

  • 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

    Each closed section becomes a single editable recap line above the open one.

Related patterns