All patterns

Progress Bar Complete

The last segment fills, then the completion colour runs left to right across the whole bar.

achievementpremiumfriendlyautomatic · finite · starter · ~1.1s
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.

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

/**
 * Vibary · Progress Bar Complete
 *
 * The last segment of a checklist lands, and the bar answers as one
 * object: a wave of the completion colour runs left to right across
 * every segment, the count is replaced by a state, and a check draws
 * itself beside it.
 *
 * The wave is the whole idea. A finished checklist is not five separate
 * done things, it is one done thing, and colour travelling across the
 * full width says that in a way a light sweep — which would play the
 * same whether the bar was full or half empty — cannot.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The empty track is mixed from the inherited text color; the progress
 * and completion colours are semantic and stay literal.
 * Works with zero props; tune via `variant`, `total`, `done`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ProgressBarCompleteProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Number of segments in the bar. */
  total?: number;
  /** Segments already filled when the sequence starts. */
  done?: number;
  /** Heading above the bar. */
  label?: string;
  /** Line shown once every segment is filled. */
  doneLabel?: string;
  /** Colour of an in-progress segment. Semantic, so it stays literal. */
  accent?: string;
  /** Colour the bar resolves to. Semantic, so it stays literal. */
  doneColor?: string;
  /** Fires once the completion wave has crossed the bar. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** Beat before the final segment fills. */
  delay: number;
  /** How long that segment takes. */
  fill: number;
  /** Gap between neighbouring segments turning over. */
  wave: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A near-simultaneous turn. For a bar in a dense settings list.
  subtle: { delay: 0.1, fill: 0.32, wave: 0.03 },
  // The all-purpose setting: the wave is readable as a direction.
  default: { delay: 0.18, fill: 0.46, wave: 0.055 },
  // A slower travel across the bar, for a completion screen.
  playful: { delay: 0.24, fill: 0.6, wave: 0.08 },
};

const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function ProgressBarComplete({
  variant = "default",
  total = 5,
  done = 4,
  label = "Workspace setup",
  doneLabel = "All steps complete",
  accent = "#5B8DEF",
  doneColor = "#2E9E6B",
  onComplete,
}: ProgressBarCompleteProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const still = !!reduceMotion;

  const count = Math.max(1, total);
  const filled = Math.min(Math.max(done, 0), count - 1);

  // Reduced motion: the bar is simply full. Both that and the reset a new
  // run needs are render-time facts, so the run key lives in state and is
  // compared during render; the effect is left owning only the timer.
  const runKey = `${still}:${cfg.delay}:${cfg.fill}`;
  const [run, setRun] = useState({ key: runKey, complete: still });
  if (run.key !== runKey) setRun({ key: runKey, complete: still });
  const complete = run.key === runKey ? run.complete : still;

  useEffect(() => {
    if (still) return;
    const timer = setTimeout(
      () => setRun({ key: runKey, complete: true }),
      (cfg.delay + cfg.fill) * 1000
    );
    return () => clearTimeout(timer);
  }, [still, cfg.delay, cfg.fill, runKey]);

  return (
    <div style={{ width: 284, display: "flex", flexDirection: "column", gap: 10 }}>
      <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
        <span style={{ fontSize: 13, fontWeight: 620 }}>{label}</span>
        <span
          style={{
            marginLeft: "auto",
            fontSize: 11.5,
            color: tone(52),
            fontVariantNumeric: "tabular-nums",
          }}
        >
          {`${complete ? count : filled} of ${count}`}
        </span>
      </div>

      <div
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={count}
        aria-valuenow={complete ? count : filled}
        aria-label={label}
        style={{ display: "flex", gap: 4 }}
      >
        {Array.from({ length: count }, (_, index) => {
          const last = index === count - 1;
          const on = index < filled || (last && complete);
          return (
            <span
              key={index}
              style={{
                flex: 1,
                height: 7,
                borderRadius: 999,
                background: tone(11),
                overflow: "hidden",
              }}
            >
              <motion.span
                style={{
                  display: "block",
                  height: "100%",
                  borderRadius: 999,
                  transformOrigin: "left center",
                }}
                initial={{
                  scaleX: index < filled ? 1 : 0,
                  backgroundColor: accent,
                }}
                animate={{
                  scaleX: on ? 1 : 0,
                  backgroundColor: complete ? doneColor : accent,
                }}
                transition={{
                  scaleX: {
                    duration: last && !still ? cfg.fill : 0,
                    delay: last && !still ? cfg.delay : 0,
                    ease: [0.33, 0, 0.2, 1],
                  },
                  // The wave: each segment turns over a beat after the one
                  // to its left, so the acknowledgement has a direction.
                  backgroundColor: {
                    duration: still ? 0 : 0.24,
                    delay: still ? 0 : index * cfg.wave,
                    ease: "easeOut",
                  },
                }}
                onAnimationComplete={
                  last && onComplete ? () => onComplete() : undefined
                }
              />
            </span>
          );
        })}
      </div>

      <div style={{ minHeight: 17, fontSize: 11.5 }}>
        <AnimatePresence initial={false} mode="wait">
          <motion.span
            key={complete ? "done" : "pending"}
            initial={{ opacity: 0, y: still ? 0 : 4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: still ? 0 : -4 }}
            transition={{ duration: still ? 0 : 0.22, ease: "easeOut" }}
            style={{
              display: "inline-flex",
              alignItems: "center",
              gap: 6,
              color: complete ? doneColor : tone(50),
              fontWeight: complete ? 600 : 500,
            }}
          >
            {complete && (
              <svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden>
                <motion.path
                  d="M3.4 7.3 5.8 9.7 10.7 4.5"
                  stroke="currentColor"
                  strokeWidth="1.7"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  initial={{ pathLength: still ? 1 : 0 }}
                  animate={{ pathLength: 1 }}
                  transition={{
                    duration: still ? 0 : 0.26,
                    delay: still ? 0 : count * cfg.wave,
                    ease: "easeOut",
                  }}
                />
              </svg>
            )}
            {complete ? doneLabel : `${count - filled} step left`}
          </motion.span>
        </AnimatePresence>
      </div>
    </div>
  );
}

About this pattern

What a segmented bar should do the moment its final piece lands. The last segment fills from its left edge, and then every segment in the bar turns to the completion colour in sequence, each a beat after the one before it. The wave is the argument: a finished checklist is one done thing rather than five separate done things, and colour travelling the full width says so with a direction and an end. A generic highlight sweep would have played identically over a half-empty bar. The count is replaced by a state rather than incremented, and a check strokes itself in beside it once the wave has arrived.

Setup checklist finishedMulti-step form completeCourse modules doneOnboarding tasks cleared

Where it shows up

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

  • Finish setting upTwo left before you can take payments
    Verify your email
    Add a business address
    Connect a bank account
    Turn on two-factor
    Connect bank
    Setup checklist

    Segmented account-setup indicator resolving to a single completed state.

Related patterns