All patterns

Order Tracking Progress

A shipment walks its stages while the connector fills from stop to stop.

commercefriendlycalmautomatic · finite · starter · ~1.4s
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.

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

/**
 * Vibary · Order Tracking Progress
 *
 * A shipment walks its stages: the connector fills from stop to stop,
 * each stop marks itself as the fill reaches it, and the live detail
 * settles in once the track catches up with reality. The travel is the
 * information — you can see how far along the parcel is before you have
 * read a single label.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the tracker reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `currentStage`, `etaLabel`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type OrderTrackingProgressProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Index of the stage the shipment has reached (0-based). */
  currentStage?: number;
  /** Reference shown in the header. */
  reference?: string;
  /** Delivery estimate shown beside the reference. */
  etaLabel?: string;
  /** Fires once the track has finished filling. */
  onSettled?: () => void;
};

type VariantConfig = {
  /** Seconds for one connector segment to fill. */
  segment: number;
  /** Fraction of a segment the next one starts early — keeps it a walk, not a march. */
  overlap: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: a delivery tracker is read, not admired. Every spring is
// at or above a 0.8 damping ratio so each stop marks itself once and
// stops, and only the dots and the rail move — the stage labels change
// opacity and nothing else, because text that springs is text that is
// harder to read at the exact moment someone is trying to.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost instant — for an orders list where four of these are on screen.
  subtle: {
    segment: 0.24,
    overlap: 0.4,
    spring: { type: "spring", stiffness: 520, damping: 42 },
  },
  // The fill reads as travel. All-purpose.
  default: {
    segment: 0.36,
    overlap: 0.25,
    spring: { type: "spring", stiffness: 400, damping: 34 },
  },
  // A slower walk for a dedicated tracking screen, where the journey is
  // the whole page. Longer travel, same single settle.
  playful: {
    segment: 0.5,
    overlap: 0.15,
    spring: { type: "spring", stiffness: 340, damping: 31 },
  },
};

const ACCENT = "#2F9E6E";

/** 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 fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const STAGES = ["Placed", "Packed", "In transit", "Out for delivery"] as const;

const DETAIL = {
  headline: "Leaving the depot tonight",
  body: "On the van tomorrow morning. No signature needed.",
};

export default function OrderTrackingProgress({
  variant = "default",
  currentStage = 2,
  reference = "Shipment 40-8812",
  etaLabel = "Arrives tomorrow",
  onSettled,
}: OrderTrackingProgressProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const reached = Math.max(0, Math.min(currentStage, STAGES.length - 1));

  // Each segment starts slightly before the one ahead of it finishes, so
  // the fill reads as one continuous move rather than four separate ones.
  const step = cfg.segment * (1 - cfg.overlap);
  const timeAt = (index: number) => (reduceMotion ? 0 : index * step);
  const finished = timeAt(reached) + (reduceMotion ? 0.2 : cfg.segment);

  return (
    <div
      style={{
        width: 342,
        padding: "15px 18px 16px",
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        fontFamily: "inherit",
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "baseline",
          justifyContent: "space-between",
          gap: 10,
          marginBottom: 18,
        }}
      >
        <span style={{ fontSize: 13, fontWeight: 650 }}>{reference}</span>
        <span style={{ fontSize: 11.5, fontWeight: 600, color: ACCENT }}>
          {etaLabel}
        </span>
      </div>

      <div style={{ position: "relative" }}>
        {/* The rail spans dot-centre to dot-centre, so the fill lines up
            with the stops instead of running past the outer two. */}
        <div
          aria-hidden
          style={{
            position: "absolute",
            left: `${100 / (STAGES.length * 2)}%`,
            right: `${100 / (STAGES.length * 2)}%`,
            top: 8,
            height: 3,
            display: "flex",
            borderRadius: 3,
            background: tone(11),
            overflow: "hidden",
          }}
        >
          {STAGES.slice(1).map((stage, index) => (
            <motion.div
              key={stage}
              initial={
                reduceMotion
                  ? { opacity: index < reached ? 1 : 0 }
                  : { scaleX: 0 }
              }
              animate={
                reduceMotion
                  ? { opacity: index < reached ? 1 : 0 }
                  : { scaleX: index < reached ? 1 : 0 }
              }
              transition={
                reduceMotion
                  ? { duration: 0.2, ease: "easeOut" }
                  : {
                      duration: cfg.segment,
                      ease: "easeInOut",
                      delay: timeAt(index),
                    }
              }
              style={{
                flex: 1,
                background: ACCENT,
                transformOrigin: "left center",
              }}
            />
          ))}
        </div>

        <ol
          style={{
            position: "relative",
            display: "flex",
            listStyle: "none",
            margin: 0,
            padding: 0,
          }}
        >
          {STAGES.map((stage, index) => {
            const done = index <= reached;
            const at = timeAt(index);
            return (
              <li
                key={stage}
                aria-current={index === reached ? "step" : undefined}
                style={{
                  flex: 1,
                  display: "flex",
                  flexDirection: "column",
                  alignItems: "center",
                  gap: 8,
                  minWidth: 0,
                }}
              >
                <motion.span
                  initial={
                    reduceMotion
                      ? false
                      : { scale: done ? 0.5 : 1, opacity: done ? 0 : 1 }
                  }
                  animate={{ scale: 1, opacity: 1 }}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : { ...cfg.spring, delay: at }
                  }
                  style={{
                    display: "grid",
                    placeItems: "center",
                    width: 19,
                    height: 19,
                    borderRadius: 999,
                    background: done ? ACCENT : tone(9),
                    border: done ? "none" : `1.5px solid ${tone(18)}`,
                    color: "#FFFFFF",
                  }}
                >
                  {done && (
                    <svg
                      width="11"
                      height="11"
                      viewBox="0 0 20 20"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2.6"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden
                    >
                      <motion.path
                        d="M4.5 10.5 8.4 14.3 15.5 6"
                        initial={{ pathLength: reduceMotion ? 1 : 0 }}
                        animate={{ pathLength: 1 }}
                        transition={
                          reduceMotion
                            ? { duration: 0 }
                            : {
                                duration: cfg.segment * 0.7,
                                ease: "easeOut",
                                delay: at + cfg.segment * 0.2,
                              }
                        }
                      />
                    </svg>
                  )}
                </motion.span>

                {/* Labels change opacity only. A stage name that scaled
                    or slid would be moving text at the exact moment
                    someone is reading it. */}
                <motion.span
                  initial={{ opacity: reduceMotion ? (done ? 1 : 0.42) : 0.28 }}
                  animate={{ opacity: done ? 1 : 0.42 }}
                  transition={{
                    duration: 0.28,
                    ease: "easeOut",
                    delay: at + (reduceMotion ? 0 : cfg.segment * 0.3),
                  }}
                  style={{
                    fontSize: 10.5,
                    fontWeight: done ? 620 : 500,
                    lineHeight: 1.25,
                    textAlign: "center",
                  }}
                >
                  {stage}
                </motion.span>
              </li>
            );
          })}
        </ol>
      </div>

      <motion.div
        initial={{ opacity: 0, y: reduceMotion ? 0 : 8 }}
        animate={{ opacity: 1, y: 0 }}
        transition={
          reduceMotion
            ? { duration: 0.2, ease: "easeOut", delay: 0.2 }
            : { ...cfg.spring, delay: finished - cfg.segment * 0.35 }
        }
        onAnimationComplete={onSettled}
        style={{
          marginTop: 16,
          display: "flex",
          alignItems: "center",
          gap: 10,
          padding: "10px 12px",
          borderRadius: 12,
          background: tone(7),
          border: `1px solid ${tone(11)}`,
        }}
      >
        <span
          style={{
            display: "grid",
            placeItems: "center",
            width: 28,
            height: 28,
            flexShrink: 0,
            borderRadius: 9,
            background: `color-mix(in srgb, ${ACCENT} 16%, transparent)`,
            color: ACCENT,
          }}
        >
          <svg
            width="15"
            height="15"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.8"
            strokeLinecap="round"
            strokeLinejoin="round"
            aria-hidden
          >
            <path d="M2.5 7.5h11v9h-11z" />
            <path d="M13.5 10.5h4l4 3.2v2.8h-8z" />
            <circle cx="6.5" cy="18" r="1.9" />
            <circle cx="17" cy="18" r="1.9" />
          </svg>
        </span>
        <span style={{ minWidth: 0 }}>
          <span style={{ display: "block", fontSize: 12.5, fontWeight: 620 }}>
            {DETAIL.headline}
          </span>
          <span style={{ display: "block", fontSize: 11, opacity: 0.55 }}>
            {DETAIL.body}
          </span>
        </span>
      </motion.div>
    </div>
  );
}

About this pattern

The card a shopper opens from a shipping notification. Each connector segment fills toward the next stop and the stop marks itself as the fill arrives, so the distance travelled is legible before a single label has been read; segments overlap slightly so the whole run reads as one move rather than four. Stage names change opacity and nothing else — moving text at the moment someone is reading it costs more than it buys. The live detail settles in once the track catches up.

Shipment tracking cardDelivery status screenFulfilment stagesPost-purchase email landing

Where it shows up

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

  • 10:15
    Out for deliveryRidgeline Supply · order 4821
    Ridgeline GTBone · US 9 · Qty 1$132.00
    Merino crew sock, 2-pack$24.00
    Shipping$0.00
    Tax$13.65
    Total$169.65
    Track order
    Order tracking

    Named stages fill left to right as the job moves through the kitchen and out the door.

Related patterns