Progress Bar Complete
The last segment fills, then the completion colour runs left to right across the whole bar.
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, 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Setup checklist
Segmented account-setup indicator resolving to a single completed state.
Related patterns
- First Time BadgeThe first time an account does something, a small rosette settles beside the row and a marker line follows.
- Challenge CompleteA seal presses onto the finished challenge card and settles a couple of degrees off square.
- Daily Goal MetThe day's tile turns over from its running total to its completed face.