Step Progress Fill
A segmented bar sweeps one stage at a time while each label lifts to full strength as it becomes current.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Step Progress Fill
*
* A segmented bar for work that arrives in named stages. Each segment
* sweeps from its left edge while its stage runs, and its label lifts
* from muted to full strength as it becomes current. Segments beat a
* single continuous bar here because the user learns the shape of the
* job: four stages, this one is the third, one of them is slow.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the card reads
* correctly on a light page and on a dark one.
* Works with zero props; pass `steps` and `current` for real use.
* Requires the automatic JSX runtime (default since React 17).
*/
export type StepProgressFillProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Stage labels, in order. */
steps?: string[];
/** Index of the running stage. Equal to `steps.length` once finished.
* Left undefined, the component walks itself so the file runs as-is. */
current?: number;
/** Stand-in stage duration used only while uncontrolled, in ms. */
stepMs?: number;
/** Card title. */
title?: string;
/** Fill color. A state color, so it stays literal. */
accent?: string;
/** Card width — px number or any CSS length. */
width?: number | string;
/** Fires when the last stage completes. */
onComplete?: () => void;
};
type VariantConfig = {
/** Seconds for a segment to sweep from empty to full. */
fillSeconds: number;
/** Seconds for a label to change strength. */
labelSeconds: number;
/** Resting opacity for stages not yet reached. */
upcomingOpacity: number;
/** Resting opacity for stages already finished. */
doneOpacity: number;
};
// Quality rule: labels change strength and nothing else. Type that
// grows as it becomes current reads as a zoom, and a bar this small
// cannot afford the distraction. Variants differ in sweep speed only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick, flat sweeps. For a background job the user glances at.
subtle: {
fillSeconds: 0.5,
labelSeconds: 0.2,
upcomingOpacity: 0.32,
doneOpacity: 0.55,
},
// The all-purpose setting: the sweep is legible without becoming the
// thing you are watching.
default: {
fillSeconds: 0.72,
labelSeconds: 0.26,
upcomingOpacity: 0.3,
doneOpacity: 0.6,
},
// A slower sweep and a wider contrast between stages, for an import
// the user is sitting through.
playful: {
fillSeconds: 0.95,
labelSeconds: 0.32,
upcomingOpacity: 0.26,
doneOpacity: 0.66,
},
};
const ACCENT = "#4C7DF0";
/** Theme-adaptive neutral: mixing the inherited text color with
* transparent yields tracks and borders that are correctly toned in
* either theme. The fill stays literal — it is a state color. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE_STEPS = ["Upload", "Validate", "Import", "Verify"];
export default function StepProgressFill({
variant = "default",
steps = SAMPLE_STEPS,
current,
stepMs = 1200,
title = "Importing customer records",
accent = ACCENT,
width = 336,
onComplete,
}: StepProgressFillProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [selfStep, setSelfStep] = useState(0);
const controlled = current !== undefined;
// Uncontrolled by default so the file runs on its own; the moment a
// caller passes `current`, this timer stays out of the way.
useEffect(() => {
if (controlled || selfStep >= steps.length) return;
const timer = setTimeout(() => setSelfStep((value) => value + 1), stepMs);
return () => clearTimeout(timer);
}, [controlled, selfStep, steps.length, stepMs]);
const active = controlled ? current : selfStep;
const finished = active >= steps.length;
useEffect(() => {
if (finished) onComplete?.();
}, [finished, onComplete]);
return (
<div
aria-busy={!finished}
style={{
width,
padding: 18,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(4),
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
marginBottom: 16,
}}
>
<span style={{ fontSize: 13.5, fontWeight: 600 }}>{title}</span>
<span
style={{
fontSize: 11.5,
opacity: 0.5,
fontVariantNumeric: "tabular-nums",
}}
>
{finished ? "Done" : `${Math.min(active + 1, steps.length)}/${steps.length}`}
</span>
</div>
<div style={{ display: "flex", gap: 6 }}>
{steps.map((label, index) => {
const isDone = index < active;
const isCurrent = index === active;
return (
<div key={label} style={{ flex: 1 }}>
<div
style={{
height: 4,
borderRadius: 3,
background: tone(10),
overflow: "hidden",
}}
>
{/* scaleX from the left edge: a transform, so the sweep
costs no layout and lands on a subpixel boundary
without reflowing the track around it. */}
<motion.div
initial={{ scaleX: 0 }}
animate={{
scaleX: isDone || isCurrent ? 1 : 0,
// Finished stages step back so the running one is
// the brightest thing in the row.
opacity: isDone ? 0.55 : 1,
}}
transition={{
duration:
reduceMotion || !isCurrent ? 0.16 : cfg.fillSeconds,
ease: isCurrent ? [0.32, 0.06, 0.28, 1] : "easeOut",
}}
style={{
height: "100%",
borderRadius: 3,
background: accent,
transformOrigin: "left center",
}}
/>
</div>
{/* Labels only change strength. Constant size, constant
position, so the row of stages stays a stable ruler. */}
<motion.div
initial={false}
animate={{
opacity: isCurrent
? 1
: isDone
? cfg.doneOpacity
: cfg.upcomingOpacity,
}}
transition={{ duration: cfg.labelSeconds, ease: "easeOut" }}
style={{
marginTop: 8,
fontSize: 11,
fontWeight: isCurrent ? 600 : 500,
letterSpacing: 0.2,
color: isCurrent ? accent : "inherit",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{label}
</motion.div>
</div>
);
})}
</div>
<motion.div
initial={false}
animate={{ opacity: finished ? 1 : 0.55 }}
transition={{ duration: cfg.labelSeconds, ease: "easeOut" }}
style={{
marginTop: 16,
paddingTop: 13,
borderTop: `1px solid ${tone(10)}`,
fontSize: 12,
}}
>
{finished
? "12,480 records imported"
: `Working through ${(steps[Math.min(active, steps.length - 1)] ?? "").toLowerCase()}`}
</motion.div>
</div>
);
}About this pattern
For long work that arrives in named stages — upload, validate, import, verify. Each segment sweeps from its left edge while its stage runs, so the bar reports both how far along the job is and what it is actually doing, which a single undivided bar cannot. Segments also teach the shape of the work: four stages, this is the third, and one of them is always the slow one. The labels carry their state entirely through strength and color; they never resize or move, because a row of stage names doubles as a ruler and a ruler whose marks change size is no longer measuring anything.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Code review
A deployment shows named build stages that light up as each one begins.
Related patterns
- Long Task EstimateA bar for work measured in minutes, with the time-remaining wording softening as it runs out.
- Background Refresh HintA two-pixel tinted band travels the panel's top edge while data refetches, without interrupting reading.
- Cache Hit InstantCached content gets no entrance at all; only the values that actually changed animate.