All patterns

Long Task Estimate

A bar for work measured in minutes, with the time-remaining wording softening as it runs out.

loadingcalmpremiumautomatic · finite · intermediate · ~6.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.

288 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Long Task Estimate
 *
 * The waiting state for work measured in minutes. The bar advances on a
 * decelerating curve while the estimate underneath swaps through
 * progressively calmer wording — a precise figure early, when a figure is
 * useful, and a reassurance late, when counting seconds would only make
 * the wait louder.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `stages`, `durationMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type EstimateStage = {
  /** Fill percentage at which this wording takes over. */
  at: number;
  /** The estimate itself. Precise early, softer as it runs out. */
  label: string;
};

export type LongTaskEstimateProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** What the job is called. */
  title?: string;
  /** The steady line under the estimate. */
  hint?: string;
  /** Wording thresholds, in ascending order of `at`. */
  stages?: EstimateStage[];
  /** Label once the bar is full. */
  doneLabel?: string;
  /** How long the whole run takes, in ms. */
  durationMs?: number;
  /** Accent for the fill. */
  accent?: string;
  /** Width — px number or any CSS length. */
  width?: number | string;
  /** Fires when the bar reaches the end. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** px the wording travels as it swaps. */
  swapY: number;
  /** Crossfade length for one wording swapping to the next. */
  swapSeconds: number;
  /** Spring the finished state lands on. */
  spring: { type: "spring"; stiffness: number; damping: number };
};

// The estimate is a sentence someone reads while waiting, so it never
// scales and never overshoots: damping ratios (ζ = damping / 2√stiffness)
// stay at or above 0.87. Variants change how far the wording travels as
// it swaps, not how much it wobbles when it gets there.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.01 — the wording changes almost in place. For a panel sitting
  // beside content someone is still reading.
  subtle: {
    swapY: 3,
    swapSeconds: 0.19,
    spring: { type: "spring", stiffness: 550, damping: 47 },
  },
  // ζ ≈ 0.90. The all-purpose setting.
  default: {
    swapY: 7,
    swapSeconds: 0.28,
    spring: { type: "spring", stiffness: 400, damping: 36 },
  },
  // ζ ≈ 0.89, more travel — for a full-page export screen where the
  // estimate is the only thing on it.
  playful: {
    swapY: 13,
    swapSeconds: 0.37,
    spring: { type: "spring", stiffness: 290, damping: 30 },
  },
};

/** 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 = "#7C7CF0";
const DONE = "#10B981";

// Wording softens as the number stops being worth quoting. "About four
// minutes" is useful; "eleven seconds" is a stopwatch you didn't ask for.
const SAMPLE_STAGES: EstimateStage[] = [
  { at: 0, label: "Working out how long this will take" },
  { at: 14, label: "About 4 minutes left" },
  { at: 42, label: "About a minute left" },
  { at: 71, label: "Less than a minute left" },
  { at: 90, label: "Almost there" },
];

export default function LongTaskEstimate({
  variant = "default",
  title = "Exporting workspace archive",
  hint = "You can keep working — we'll let you know when it lands.",
  stages = SAMPLE_STAGES,
  doneLabel = "Archive ready to download",
  durationMs = 6400,
  accent = ACCENT,
  width = 320,
  onComplete,
}: LongTaskEstimateProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // One source of truth for the run. The bar is a scaleX off this value
  // rather than a width, so the fill is composited instead of relaid out
  // on every frame, and the wording reads the same number the bar does.
  const progress = useMotionValue(0);
  const scaleX = useTransform(progress, [0, 100], [0, 1]);

  const [stageIndex, setStageIndex] = useState(0);
  const [done, setDone] = useState(false);

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

  useEffect(() => {
    // Decelerating, never linear: a long job that advances at a constant
    // rate reads as a countdown someone can check your work against. The
    // curve spends its speed early, where the work usually is.
    const controls = animate(progress, 100, {
      duration: durationMs / 1000,
      ease: [0.12, 0.62, 0.32, 1],
      onComplete: () => {
        setDone(true);
        onCompleteRef.current?.();
      },
    });
    // Wording is derived from the same value, so it can never disagree
    // with the bar — no second timer to drift against.
    const unsubscribe = progress.on("change", (value) => {
      let next = 0;
      for (let index = 0; index < stages.length; index++) {
        if (value >= stages[index].at) next = index;
      }
      setStageIndex(next);
    });
    return () => {
      controls.stop();
      unsubscribe();
    };
  }, [durationMs, progress, stages]);

  const stage = stages[Math.min(stageIndex, stages.length - 1)];
  const estimate = done ? doneLabel : stage.label;
  const swapY = reduceMotion ? 0 : cfg.swapY;

  return (
    <div
      style={{
        width,
        padding: "15px 16px 16px",
        borderRadius: 14,
        background: tone(5),
        border: `1px solid ${tone(10)}`,
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 9 }}>
        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: done ? 1 : 0.55 }}
          transition={{ duration: 0.24, ease: "easeOut" }}
          style={{ display: "inline-flex", color: done ? DONE : "inherit" }}
        >
          {done ? (
            <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
              <path
                d="M8 1.6v8.2M8 9.8 5.1 6.9M8 9.8l2.9-2.9M2.8 12.4h10.4"
                stroke="currentColor"
                strokeWidth="1.5"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          ) : (
            <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
              <path
                d="M8 1.6h0M8 4.2v4l2.6 1.6"
                stroke="currentColor"
                strokeWidth="1.5"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
              <circle cx="8" cy="8" r="6.3" stroke="currentColor" strokeWidth="1.3" />
            </svg>
          )}
        </motion.span>
        <span style={{ fontSize: 13, fontWeight: 600 }}>{title}</span>
      </div>

      {/* The track. scaleX from the left edge is a transform: no layout
          work per frame, and it lands on exactly 100%. */}
      <div
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={100}
        // The milestone the wording is standing on, not the animated frame:
        // assistive tech should hear where the job is, not follow the fill.
        aria-valuenow={done ? 100 : stage.at}
        aria-valuetext={estimate}
        style={{
          marginTop: 13,
          height: 5,
          borderRadius: 3,
          background: tone(9),
          overflow: "hidden",
        }}
      >
        <motion.div
          style={{
            height: "100%",
            borderRadius: 3,
            background: accent,
            transformOrigin: "left center",
            scaleX,
          }}
        />
      </div>

      {/* The wording swaps inside a fixed line box, so the panel height is
          settled from the first frame and no swap can nudge the hint. */}
      <div
        aria-live="polite"
        style={{
          position: "relative",
          height: 18,
          marginTop: 11,
          overflow: "hidden",
        }}
      >
        <AnimatePresence initial={false}>
          <motion.span
            // Keyed on the sentence: a new estimate is a new element, so the
            // outgoing one leaves while the incoming one arrives, and
            // identical wording never re-animates. Both sit in the same
            // absolute box, so the swap is a crossfade rather than a shuffle.
            key={estimate}
            initial={{ opacity: 0, y: swapY }}
            animate={{ opacity: done ? 1 : 0.78, y: 0 }}
            exit={{ opacity: 0, y: -swapY }}
            transition={{
              opacity: { duration: cfg.swapSeconds, ease: "easeOut" },
              y: cfg.spring,
            }}
            style={{
              position: "absolute",
              inset: 0,
              display: "block",
              fontSize: 12.5,
              fontWeight: 550,
              lineHeight: "18px",
              color: done ? DONE : "inherit",
              whiteSpace: "nowrap",
            }}
          >
            {estimate}
          </motion.span>
        </AnimatePresence>
      </div>

      <div style={{ marginTop: 4, fontSize: 11.5, opacity: 0.5, lineHeight: 1.45 }}>
        {hint}
      </div>
    </div>
  );
}

About this pattern

Exports, migrations, big imports — the waits long enough that a bar alone becomes an accusation. The fill runs on a decelerating curve rather than a constant rate, and the sentence underneath swaps as it advances: a precise figure early, when a figure helps someone decide whether to stay; softer phrasing late, because counting down the last eleven seconds only makes the wait louder. Both the bar and the wording are derived from a single motion value, so they can never disagree. Each new estimate crossfades in a fixed line box with a few pixels of travel — the panel height is settled from the first frame, and the sentence holds one size throughout.

Data exportLarge importAccount migrationVideo processing

Where it shows up

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

  • Set up your workspaceStep 2 of 4
    What should we call it?
    Ridgeline
    Who else is joining?
    3 invited
    Next
    Onboarding flow

    Archive preparation states the wait in coarse terms and tells you it will notify you.

Related patterns