Processing Steps Check
Three stages clear across a horizontal rail, each hand-off filling the segment to the next marker.
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 { Fragment, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Processing Steps Check
*
* A three-stage job reporting itself across a horizontal rail. A stage
* finishing is two beats, not one: the marker settles into its finished
* state, and only then does the rail between it and the next stage fill
* left to right. That fill is what carries the eye along — without it,
* three markers changing colour is three unrelated events.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the rail reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `steps`, `stepMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ProcessingStep = {
label: string;
/** Printed under the label once the stage is behind you. */
took: string;
};
export type ProcessingStepsCheckProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The stages, in order. */
steps?: ProcessingStep[];
/** How long each stage runs, in ms. */
stepMs?: number;
/** Line under the rail once every stage is behind you. */
doneLabel?: string;
/** Line under the rail while the job runs. */
runningLabel?: string;
/** Accent for finished markers and filled rail. */
accent?: string;
/** Width — px number or any CSS length. */
width?: number | string;
/** Fires when the last stage finishes. */
onComplete?: () => void;
};
type VariantConfig = {
/** Seconds a rail segment takes to fill. */
railSeconds: number;
/** Beat between a marker settling and its rail starting. */
handoff: number;
/** Seconds the finished mark takes to write itself. */
markSeconds: number;
/** Spring the closing line arrives on. */
spring: { type: "spring"; stiffness: number; damping: number };
};
// The rail is a measurement, so it never overshoots its segment: the
// fills are eased tweens and the one spring in the file (ζ = damping /
// 2√stiffness ≥ 0.89) only carries the closing line.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick and matter-of-fact. For a job someone runs twenty times a day.
subtle: {
railSeconds: 0.24,
handoff: 0.06,
markSeconds: 0.2,
spring: { type: "spring", stiffness: 500, damping: 45 },
},
// The all-purpose setting: the hand-off between stages is readable.
default: {
railSeconds: 0.36,
handoff: 0.1,
markSeconds: 0.26,
spring: { type: "spring", stiffness: 400, damping: 36 },
},
// A slower hand-off for a publish flow that runs once and is watched.
playful: {
railSeconds: 0.5,
handoff: 0.14,
markSeconds: 0.32,
spring: { type: "spring", stiffness: 320, damping: 32 },
},
};
/** 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 = "#10B981";
const NODE = 26;
const SAMPLE_STEPS: ProcessingStep[] = [
{ label: "Uploading", took: "0.9s" },
{ label: "Converting", took: "2.4s" },
{ label: "Publishing", took: "1.1s" },
];
export default function ProcessingStepsCheck({
variant = "default",
steps = SAMPLE_STEPS,
stepMs = 1400,
doneLabel = "Live at share.example.com/q3-review",
runningLabel = "Keep this open until the last stage clears",
accent = ACCENT,
width = 320,
onComplete,
}: ProcessingStepsCheckProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// How many stages are behind us. `current` is therefore this same
// number — one state for both, so they can never disagree.
const [done, setDone] = useState(0);
const finished = done >= steps.length;
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
useEffect(() => {
if (finished) {
onCompleteRef.current?.();
return;
}
const timer = setTimeout(() => setDone((count) => count + 1), stepMs);
return () => clearTimeout(timer);
}, [done, finished, stepMs]);
return (
<div style={{ width }}>
<div style={{ display: "flex", alignItems: "flex-start" }}>
{steps.map((step, index) => {
const isDone = index < done;
const isCurrent = index === done;
return (
<Fragment key={step.label}>
{index > 0 && (
<span
aria-hidden
style={{
flex: 1,
height: 2,
marginTop: NODE / 2 - 1,
borderRadius: 2,
background: tone(9),
overflow: "hidden",
}}
>
{/* scaleX from the left edge: a transform, so the rail
costs nothing per frame and lands exactly on the
next marker rather than near it. */}
<motion.span
initial={{ scaleX: 0 }}
animate={{ scaleX: index <= done ? 1 : 0 }}
transition={{
duration: reduceMotion ? 0 : cfg.railSeconds,
ease: "easeInOut",
// The rail starts only after the marker behind it
// has settled, which is what makes the pair read as
// a hand-off rather than two things at once.
delay: index <= done && !reduceMotion ? cfg.handoff : 0,
}}
style={{
display: "block",
height: "100%",
borderRadius: 2,
background: accent,
transformOrigin: "left center",
}}
/>
</span>
)}
<div
style={{
width: 88,
display: "flex",
flexDirection: "column",
alignItems: "center",
flexShrink: 0,
}}
>
<Marker
index={index}
isDone={isDone}
isCurrent={isCurrent}
accent={accent}
cfg={cfg}
still={Boolean(reduceMotion)}
/>
<span
style={{
marginTop: 7,
fontSize: 11.5,
fontWeight: 600,
// The label carries its state in strength alone. It
// never moves and never resizes: a row of stage names
// is a ruler, and a ruler with shifting marks stops
// measuring anything.
opacity: isDone ? 0.85 : isCurrent ? 1 : 0.4,
transition: "opacity 240ms ease-out",
whiteSpace: "nowrap",
}}
>
{step.label}
</span>
<span
style={{
height: 14,
fontSize: 10.5,
opacity: 0.45,
fontVariantNumeric: "tabular-nums",
}}
>
<motion.span
initial={false}
animate={{ opacity: isDone ? 1 : 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
style={{ display: "inline-block" }}
>
{step.took}
</motion.span>
</span>
</div>
</Fragment>
);
})}
</div>
{/* The closing line sits in a reserved box, so the rail above it
cannot move when the wording changes. */}
<div
aria-live="polite"
style={{
position: "relative",
height: 17,
marginTop: 10,
fontSize: 11.5,
textAlign: "center",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={finished ? "done" : "running"}
initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
transition={{
opacity: { duration: 0.22, ease: "easeOut" },
y: cfg.spring,
}}
style={{
position: "absolute",
inset: 0,
display: "block",
lineHeight: "17px",
color: finished ? accent : "inherit",
opacity: finished ? 1 : 0.5,
whiteSpace: "nowrap",
}}
>
{finished ? doneLabel : runningLabel}
</motion.span>
</AnimatePresence>
</div>
</div>
);
}
/** One marker. Three states share one circle, so there is never a frame
* where the rail has a gap in it. */
function Marker({
index,
isDone,
isCurrent,
accent,
cfg,
still,
}: {
index: number;
isDone: boolean;
isCurrent: boolean;
accent: string;
cfg: VariantConfig;
still: boolean;
}) {
return (
<span
aria-hidden
style={{
position: "relative",
width: NODE,
height: NODE,
display: "grid",
placeItems: "center",
}}
>
{/* Waiting ground. The tints are separate layers crossfading rather
than one animated background, because a color-mix() value cannot
be interpolated by an animation. */}
<span
style={{
position: "absolute",
inset: 0,
borderRadius: 999,
background: tone(7),
border: `1px solid ${tone(12)}`,
}}
/>
<motion.span
initial={false}
animate={{ opacity: isDone || isCurrent ? 1 : 0 }}
transition={{ duration: 0.24, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
borderRadius: 999,
background: `color-mix(in srgb, ${accent} 15%, transparent)`,
border: `1px solid color-mix(in srgb, ${accent} 45%, transparent)`,
}}
/>
{/* The running stage breathes. Opacity only — a marker that grows
and shrinks would drag the rail's alignment with it. */}
{isCurrent && !still && (
<motion.span
animate={{ opacity: [0.5, 0.12, 0.5] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
style={{
position: "absolute",
inset: -4,
borderRadius: 999,
border: `2px solid ${accent}`,
}}
/>
)}
<span
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: "100%",
height: "100%",
color: isDone || isCurrent ? accent : "inherit",
}}
>
<motion.span
initial={false}
animate={{ opacity: isDone ? 0 : 1 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{
gridArea: "1 / 1",
fontSize: 11,
fontWeight: 700,
opacity: isCurrent ? 1 : 0.45,
fontVariantNumeric: "tabular-nums",
}}
>
{index + 1}
</motion.span>
<motion.span
initial={false}
animate={{ opacity: isDone ? 1 : 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ gridArea: "1 / 1", display: "inline-flex" }}
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none">
<motion.path
d="M3.6 8.5 6.5 11.4 12.4 5.1"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: isDone ? 1 : 0 }}
transition={{
duration: still ? 0 : cfg.markSeconds,
ease: "easeOut",
}}
/>
</svg>
</motion.span>
</span>
</span>
);
}About this pattern
A short server-side job — upload, convert, publish — reported as three markers on a horizontal rail. A stage clearing is two beats rather than one: the marker settles into its finished state, then the segment between it and the next marker fills left to right. That fill is what carries the eye along; without it, three markers changing colour are three unrelated events instead of one job moving. The running marker breathes in opacity only, never in size, because a marker that grows drags the rail's alignment with it. Stage names hold one size and one position throughout and carry their state in strength alone, and each elapsed time fades into a line that was already reserved for it.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Code review
Build stages clear in order with the elapsed time settling under each one.
Related patterns
- Quota Limit NudgeA usage meter nearing its cap gives one restrained nudge, then reveals the upgrade.
- Delete Confirm MorphA delete control widens in place into a question with a way out, instead of opening a dialog.
- Destructive Hold to ConfirmHolding fills the control at an honest rate; letting go early drains it back several times faster.