Order Tracking Progress
A shipment walks its stages while the connector fills from stop to stop.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Order Tracking Progress
*
* A shipment walks its stages: the connector fills from stop to stop,
* each stop marks itself as the fill reaches it, and the live detail
* settles in once the track catches up with reality. The travel is the
* information — you can see how far along the parcel is before you have
* read a single label.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the tracker reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `currentStage`, `etaLabel`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type OrderTrackingProgressProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Index of the stage the shipment has reached (0-based). */
currentStage?: number;
/** Reference shown in the header. */
reference?: string;
/** Delivery estimate shown beside the reference. */
etaLabel?: string;
/** Fires once the track has finished filling. */
onSettled?: () => void;
};
type VariantConfig = {
/** Seconds for one connector segment to fill. */
segment: number;
/** Fraction of a segment the next one starts early — keeps it a walk, not a march. */
overlap: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: a delivery tracker is read, not admired. Every spring is
// at or above a 0.8 damping ratio so each stop marks itself once and
// stops, and only the dots and the rail move — the stage labels change
// opacity and nothing else, because text that springs is text that is
// harder to read at the exact moment someone is trying to.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost instant — for an orders list where four of these are on screen.
subtle: {
segment: 0.24,
overlap: 0.4,
spring: { type: "spring", stiffness: 520, damping: 42 },
},
// The fill reads as travel. All-purpose.
default: {
segment: 0.36,
overlap: 0.25,
spring: { type: "spring", stiffness: 400, damping: 34 },
},
// A slower walk for a dedicated tracking screen, where the journey is
// the whole page. Longer travel, same single settle.
playful: {
segment: 0.5,
overlap: 0.15,
spring: { type: "spring", stiffness: 340, damping: 31 },
},
};
const ACCENT = "#2F9E6E";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const STAGES = ["Placed", "Packed", "In transit", "Out for delivery"] as const;
const DETAIL = {
headline: "Leaving the depot tonight",
body: "On the van tomorrow morning. No signature needed.",
};
export default function OrderTrackingProgress({
variant = "default",
currentStage = 2,
reference = "Shipment 40-8812",
etaLabel = "Arrives tomorrow",
onSettled,
}: OrderTrackingProgressProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const reached = Math.max(0, Math.min(currentStage, STAGES.length - 1));
// Each segment starts slightly before the one ahead of it finishes, so
// the fill reads as one continuous move rather than four separate ones.
const step = cfg.segment * (1 - cfg.overlap);
const timeAt = (index: number) => (reduceMotion ? 0 : index * step);
const finished = timeAt(reached) + (reduceMotion ? 0.2 : cfg.segment);
return (
<div
style={{
width: 342,
padding: "15px 18px 16px",
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
fontFamily: "inherit",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
marginBottom: 18,
}}
>
<span style={{ fontSize: 13, fontWeight: 650 }}>{reference}</span>
<span style={{ fontSize: 11.5, fontWeight: 600, color: ACCENT }}>
{etaLabel}
</span>
</div>
<div style={{ position: "relative" }}>
{/* The rail spans dot-centre to dot-centre, so the fill lines up
with the stops instead of running past the outer two. */}
<div
aria-hidden
style={{
position: "absolute",
left: `${100 / (STAGES.length * 2)}%`,
right: `${100 / (STAGES.length * 2)}%`,
top: 8,
height: 3,
display: "flex",
borderRadius: 3,
background: tone(11),
overflow: "hidden",
}}
>
{STAGES.slice(1).map((stage, index) => (
<motion.div
key={stage}
initial={
reduceMotion
? { opacity: index < reached ? 1 : 0 }
: { scaleX: 0 }
}
animate={
reduceMotion
? { opacity: index < reached ? 1 : 0 }
: { scaleX: index < reached ? 1 : 0 }
}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: {
duration: cfg.segment,
ease: "easeInOut",
delay: timeAt(index),
}
}
style={{
flex: 1,
background: ACCENT,
transformOrigin: "left center",
}}
/>
))}
</div>
<ol
style={{
position: "relative",
display: "flex",
listStyle: "none",
margin: 0,
padding: 0,
}}
>
{STAGES.map((stage, index) => {
const done = index <= reached;
const at = timeAt(index);
return (
<li
key={stage}
aria-current={index === reached ? "step" : undefined}
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
minWidth: 0,
}}
>
<motion.span
initial={
reduceMotion
? false
: { scale: done ? 0.5 : 1, opacity: done ? 0 : 1 }
}
animate={{ scale: 1, opacity: 1 }}
transition={
reduceMotion
? { duration: 0 }
: { ...cfg.spring, delay: at }
}
style={{
display: "grid",
placeItems: "center",
width: 19,
height: 19,
borderRadius: 999,
background: done ? ACCENT : tone(9),
border: done ? "none" : `1.5px solid ${tone(18)}`,
color: "#FFFFFF",
}}
>
{done && (
<svg
width="11"
height="11"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<motion.path
d="M4.5 10.5 8.4 14.3 15.5 6"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={
reduceMotion
? { duration: 0 }
: {
duration: cfg.segment * 0.7,
ease: "easeOut",
delay: at + cfg.segment * 0.2,
}
}
/>
</svg>
)}
</motion.span>
{/* Labels change opacity only. A stage name that scaled
or slid would be moving text at the exact moment
someone is reading it. */}
<motion.span
initial={{ opacity: reduceMotion ? (done ? 1 : 0.42) : 0.28 }}
animate={{ opacity: done ? 1 : 0.42 }}
transition={{
duration: 0.28,
ease: "easeOut",
delay: at + (reduceMotion ? 0 : cfg.segment * 0.3),
}}
style={{
fontSize: 10.5,
fontWeight: done ? 620 : 500,
lineHeight: 1.25,
textAlign: "center",
}}
>
{stage}
</motion.span>
</li>
);
})}
</ol>
</div>
<motion.div
initial={{ opacity: 0, y: reduceMotion ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut", delay: 0.2 }
: { ...cfg.spring, delay: finished - cfg.segment * 0.35 }
}
onAnimationComplete={onSettled}
style={{
marginTop: 16,
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: 12,
background: tone(7),
border: `1px solid ${tone(11)}`,
}}
>
<span
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
flexShrink: 0,
borderRadius: 9,
background: `color-mix(in srgb, ${ACCENT} 16%, transparent)`,
color: ACCENT,
}}
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M2.5 7.5h11v9h-11z" />
<path d="M13.5 10.5h4l4 3.2v2.8h-8z" />
<circle cx="6.5" cy="18" r="1.9" />
<circle cx="17" cy="18" r="1.9" />
</svg>
</span>
<span style={{ minWidth: 0 }}>
<span style={{ display: "block", fontSize: 12.5, fontWeight: 620 }}>
{DETAIL.headline}
</span>
<span style={{ display: "block", fontSize: 11, opacity: 0.55 }}>
{DETAIL.body}
</span>
</span>
</motion.div>
</div>
);
}About this pattern
The card a shopper opens from a shipping notification. Each connector segment fills toward the next stop and the stop marks itself as the fill arrives, so the distance travelled is legible before a single label has been read; segments overlap slightly so the whole run reads as one move rather than four. Stage names change opacity and nothing else — moving text at the moment someone is reading it costs more than it buys. The live detail settles in once the track catches up.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Order tracking
Named stages fill left to right as the job moves through the kitchen and out the door.
Related patterns
- Refund StatusA refund walks down its stages while the vague estimate firms up into a real date.
- Shipping Option SelectOne plate travels to the chosen speed while the arrival estimate restates itself and the total follows.
- Cart Badge CountThe cart badge rolls each changed digit in the direction of the change and settles once.
