All patterns

Processing Steps Check

Three stages clear across a horizontal rail, each hand-off filling the segment to the next marker.

feedbackpremiumcalmautomatic · finite · intermediate · ~4.2s
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.

388 lines · react + motion only
import { Fragment, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Processing Steps Check
 *
 * A three-stage job reporting itself across a horizontal rail. A stage
 * finishing is two beats, not one: the marker settles into its finished
 * state, and only then does the rail between it and the next stage fill
 * left to right. That fill is what carries the eye along — without it,
 * three markers changing colour is three unrelated events.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the rail reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `steps`, `stepMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ProcessingStep = {
  label: string;
  /** Printed under the label once the stage is behind you. */
  took: string;
};

export type ProcessingStepsCheckProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The stages, in order. */
  steps?: ProcessingStep[];
  /** How long each stage runs, in ms. */
  stepMs?: number;
  /** Line under the rail once every stage is behind you. */
  doneLabel?: string;
  /** Line under the rail while the job runs. */
  runningLabel?: string;
  /** Accent for finished markers and filled rail. */
  accent?: string;
  /** Width — px number or any CSS length. */
  width?: number | string;
  /** Fires when the last stage finishes. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** Seconds a rail segment takes to fill. */
  railSeconds: number;
  /** Beat between a marker settling and its rail starting. */
  handoff: number;
  /** Seconds the finished mark takes to write itself. */
  markSeconds: number;
  /** Spring the closing line arrives on. */
  spring: { type: "spring"; stiffness: number; damping: number };
};

// The rail is a measurement, so it never overshoots its segment: the
// fills are eased tweens and the one spring in the file (ζ = damping /
// 2√stiffness ≥ 0.89) only carries the closing line.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick and matter-of-fact. For a job someone runs twenty times a day.
  subtle: {
    railSeconds: 0.24,
    handoff: 0.06,
    markSeconds: 0.2,
    spring: { type: "spring", stiffness: 500, damping: 45 },
  },
  // The all-purpose setting: the hand-off between stages is readable.
  default: {
    railSeconds: 0.36,
    handoff: 0.1,
    markSeconds: 0.26,
    spring: { type: "spring", stiffness: 400, damping: 36 },
  },
  // A slower hand-off for a publish flow that runs once and is watched.
  playful: {
    railSeconds: 0.5,
    handoff: 0.14,
    markSeconds: 0.32,
    spring: { type: "spring", stiffness: 320, 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 — it carries meaning. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const ACCENT = "#10B981";
const NODE = 26;

const SAMPLE_STEPS: ProcessingStep[] = [
  { label: "Uploading", took: "0.9s" },
  { label: "Converting", took: "2.4s" },
  { label: "Publishing", took: "1.1s" },
];

export default function ProcessingStepsCheck({
  variant = "default",
  steps = SAMPLE_STEPS,
  stepMs = 1400,
  doneLabel = "Live at share.example.com/q3-review",
  runningLabel = "Keep this open until the last stage clears",
  accent = ACCENT,
  width = 320,
  onComplete,
}: ProcessingStepsCheckProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // How many stages are behind us. `current` is therefore this same
  // number — one state for both, so they can never disagree.
  const [done, setDone] = useState(0);
  const finished = done >= steps.length;

  const onCompleteRef = useRef(onComplete);
  useEffect(() => {
    onCompleteRef.current = onComplete;
  }, [onComplete]);

  useEffect(() => {
    if (finished) {
      onCompleteRef.current?.();
      return;
    }
    const timer = setTimeout(() => setDone((count) => count + 1), stepMs);
    return () => clearTimeout(timer);
  }, [done, finished, stepMs]);

  return (
    <div style={{ width }}>
      <div style={{ display: "flex", alignItems: "flex-start" }}>
        {steps.map((step, index) => {
          const isDone = index < done;
          const isCurrent = index === done;
          return (
            <Fragment key={step.label}>
              {index > 0 && (
                <span
                  aria-hidden
                  style={{
                    flex: 1,
                    height: 2,
                    marginTop: NODE / 2 - 1,
                    borderRadius: 2,
                    background: tone(9),
                    overflow: "hidden",
                  }}
                >
                  {/* scaleX from the left edge: a transform, so the rail
                      costs nothing per frame and lands exactly on the
                      next marker rather than near it. */}
                  <motion.span
                    initial={{ scaleX: 0 }}
                    animate={{ scaleX: index <= done ? 1 : 0 }}
                    transition={{
                      duration: reduceMotion ? 0 : cfg.railSeconds,
                      ease: "easeInOut",
                      // The rail starts only after the marker behind it
                      // has settled, which is what makes the pair read as
                      // a hand-off rather than two things at once.
                      delay: index <= done && !reduceMotion ? cfg.handoff : 0,
                    }}
                    style={{
                      display: "block",
                      height: "100%",
                      borderRadius: 2,
                      background: accent,
                      transformOrigin: "left center",
                    }}
                  />
                </span>
              )}

              <div
                style={{
                  width: 88,
                  display: "flex",
                  flexDirection: "column",
                  alignItems: "center",
                  flexShrink: 0,
                }}
              >
                <Marker
                  index={index}
                  isDone={isDone}
                  isCurrent={isCurrent}
                  accent={accent}
                  cfg={cfg}
                  still={Boolean(reduceMotion)}
                />
                <span
                  style={{
                    marginTop: 7,
                    fontSize: 11.5,
                    fontWeight: 600,
                    // The label carries its state in strength alone. It
                    // never moves and never resizes: a row of stage names
                    // is a ruler, and a ruler with shifting marks stops
                    // measuring anything.
                    opacity: isDone ? 0.85 : isCurrent ? 1 : 0.4,
                    transition: "opacity 240ms ease-out",
                    whiteSpace: "nowrap",
                  }}
                >
                  {step.label}
                </span>
                <span
                  style={{
                    height: 14,
                    fontSize: 10.5,
                    opacity: 0.45,
                    fontVariantNumeric: "tabular-nums",
                  }}
                >
                  <motion.span
                    initial={false}
                    animate={{ opacity: isDone ? 1 : 0 }}
                    transition={{ duration: 0.22, ease: "easeOut" }}
                    style={{ display: "inline-block" }}
                  >
                    {step.took}
                  </motion.span>
                </span>
              </div>
            </Fragment>
          );
        })}
      </div>

      {/* The closing line sits in a reserved box, so the rail above it
          cannot move when the wording changes. */}
      <div
        aria-live="polite"
        style={{
          position: "relative",
          height: 17,
          marginTop: 10,
          fontSize: 11.5,
          textAlign: "center",
        }}
      >
        <AnimatePresence initial={false}>
          <motion.span
            key={finished ? "done" : "running"}
            initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
            transition={{
              opacity: { duration: 0.22, ease: "easeOut" },
              y: cfg.spring,
            }}
            style={{
              position: "absolute",
              inset: 0,
              display: "block",
              lineHeight: "17px",
              color: finished ? accent : "inherit",
              opacity: finished ? 1 : 0.5,
              whiteSpace: "nowrap",
            }}
          >
            {finished ? doneLabel : runningLabel}
          </motion.span>
        </AnimatePresence>
      </div>
    </div>
  );
}

/** One marker. Three states share one circle, so there is never a frame
 *  where the rail has a gap in it. */
function Marker({
  index,
  isDone,
  isCurrent,
  accent,
  cfg,
  still,
}: {
  index: number;
  isDone: boolean;
  isCurrent: boolean;
  accent: string;
  cfg: VariantConfig;
  still: boolean;
}) {
  return (
    <span
      aria-hidden
      style={{
        position: "relative",
        width: NODE,
        height: NODE,
        display: "grid",
        placeItems: "center",
      }}
    >
      {/* Waiting ground. The tints are separate layers crossfading rather
          than one animated background, because a color-mix() value cannot
          be interpolated by an animation. */}
      <span
        style={{
          position: "absolute",
          inset: 0,
          borderRadius: 999,
          background: tone(7),
          border: `1px solid ${tone(12)}`,
        }}
      />
      <motion.span
        initial={false}
        animate={{ opacity: isDone || isCurrent ? 1 : 0 }}
        transition={{ duration: 0.24, ease: "easeOut" }}
        style={{
          position: "absolute",
          inset: 0,
          borderRadius: 999,
          background: `color-mix(in srgb, ${accent} 15%, transparent)`,
          border: `1px solid color-mix(in srgb, ${accent} 45%, transparent)`,
        }}
      />

      {/* The running stage breathes. Opacity only — a marker that grows
          and shrinks would drag the rail's alignment with it. */}
      {isCurrent && !still && (
        <motion.span
          animate={{ opacity: [0.5, 0.12, 0.5] }}
          transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
          style={{
            position: "absolute",
            inset: -4,
            borderRadius: 999,
            border: `2px solid ${accent}`,
          }}
        />
      )}

      <span
        style={{
          position: "relative",
          display: "grid",
          placeItems: "center",
          width: "100%",
          height: "100%",
          color: isDone || isCurrent ? accent : "inherit",
        }}
      >
        <motion.span
          initial={false}
          animate={{ opacity: isDone ? 0 : 1 }}
          transition={{ duration: 0.18, ease: "easeOut" }}
          style={{
            gridArea: "1 / 1",
            fontSize: 11,
            fontWeight: 700,
            opacity: isCurrent ? 1 : 0.45,
            fontVariantNumeric: "tabular-nums",
          }}
        >
          {index + 1}
        </motion.span>
        <motion.span
          initial={false}
          animate={{ opacity: isDone ? 1 : 0 }}
          transition={{ duration: 0.16, ease: "easeOut" }}
          style={{ gridArea: "1 / 1", display: "inline-flex" }}
        >
          <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
            <motion.path
              d="M3.6 8.5 6.5 11.4 12.4 5.1"
              stroke="currentColor"
              strokeWidth="1.9"
              strokeLinecap="round"
              strokeLinejoin="round"
              initial={false}
              animate={{ pathLength: isDone ? 1 : 0 }}
              transition={{
                duration: still ? 0 : cfg.markSeconds,
                ease: "easeOut",
              }}
            />
          </svg>
        </motion.span>
      </span>
    </span>
  );
}

About this pattern

A short server-side job — upload, convert, publish — reported as three markers on a horizontal rail. A stage clearing is two beats rather than one: the marker settles into its finished state, then the segment between it and the next marker fills left to right. That fill is what carries the eye along; without it, three markers changing colour are three unrelated events instead of one job moving. The running marker breathes in opacity only, never in size, because a marker that grows drags the rail's alignment with it. Stage names hold one size and one position throughout and carry their state in strength alone, and each elapsed time fades into a line that was already reserved for it.

Publish flowMedia processing jobDeploy stagesImport pipeline

Where it shows up

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

  • Ridgeline
    Changes
    Commits
    Checks
    Files
    Conversation
    ChangesNew
    Brand refresh.figPriya Raman · 2.4 MB
    Q3 planning.pdfMarcus Bell · 840 KB
    Supplier contract.docxDana Whitfield · 96 KB
    Photography brief.mdNils Bergström · 12 KB
    Invoice 4821.pdfBilling · 64 KB
    Code review

    Build stages clear in order with the elapsed time settling under each one.

Related patterns