Long Task Estimate
A bar for work measured in minutes, with the time-remaining wording softening as it runs out.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
Archive preparation states the wait in coarse terms and tells you it will notify you.
Related patterns
- Step Progress FillA segmented bar sweeps one stage at a time while each label lifts to full strength as it becomes current.
- Determinate Progress RingA ring fills from empty to full while the percentage counts up inside it.
- Background Refresh HintA two-pixel tinted band travels the panel's top edge while data refetches, without interrupting reading.