The problem
A status that only ever renders its current value makes people re-read the whole card to work out whether anything moved since they last looked. Nothing distinguishes "packing" from "packing, still" — the card looks the same either way, so the only way to know is to remember what it said before.
Animating the change carries that information in the motion itself. The puck advances along the track, so progress is legible before a single word has been read, and arriving on a card mid-transition tells you something just happened.
Use cases
- Order or delivery tracking, where the stage changes while the page is open
- A multi-step job — export, build, render — with a known number of stages
- Any short, ordered, forward-only sequence that the server drives
Not for: progress with no discrete stages — use a plain bar. Not for steps a user drives themselves; the motion here says "this moved on its own", which is misleading if they pressed something. Not for sequences that can fail or go backwards: the puck only travels one way and retiring it at the end means "done", not "stopped".
Installation
npm install gsap @gsap/react motionThen copy OrderStatus.tsx. It depends on @/lib/cn and the four icons in
@/components/icons, or pass your own through stages.
Usage
import OrderStatus from "@/patterns/order-status/OrderStatus";
<OrderStatus onComplete={() => celebrate()} />;The default four-stage delivery flow is exported as defaultOrderStages if you
want to adapt rather than replace it.
<OrderStatus
holdMs={2000}
stages={[
{
id: "queued",
title: "Queued",
subtitle: "Waiting for a worker",
meta: "Est. 30s",
icon: <Clock />,
progress: 0.33,
},
// …
]}
/>API
Deliberately small. This is a display, not a widget — most of what a consumer needs to change is the content, and the rest is the design.
| Prop | Type | Default | Description |
|---|---|---|---|
stages | readonly OrderStage[] | the delivery flow | Ordered, forward-only. Empty renders nothing. |
holdMs | number | 1100 | How long each stage is held before advancing. |
onComplete | () => void | — | Fires once, when the last stage is reached. |
className | string | — | Merged onto the root. |
OrderStage: { id: string; title: string; subtitle: string; meta: string; icon: ReactNode; progress: number }, where progress is 0–1.
Keyboard
There is nothing to operate. The card has no controls — it reports, and the sequence is driven by the server, so there is no task for a keyboard to complete. That is why this pattern does not claim keyboard support rather than claiming it and meaning "not applicable".
The information still has to reach people who are not watching it, which is what the live region below is for.
Accessibility
The card announced nothing. It advanced through four stages on its own with
no live region, so a screen reader user was told about the first stage on load
and then never again — the order could go from "Preparing" to "Delivered"
in silence. The text block is a role="status" live region now, so each stage
is announced as it arrives, politely, without interrupting.
The track is a role="progressbar" with aria-valuenow counting stages rather
than percent, and an aria-valuetext that reads "Out for Delivery — step 3 of
4". Percent would have been the easy mapping and the wrong one: the stages are
what the card is about, and "75%" does not say which one you are in.
The travelling puck is aria-hidden. It duplicates what the progressbar and
the live region already say, and its icon changes for decorative reasons.
Under prefers-reduced-motion every duration in the timeline collapses to
zero. The sequence, the stage changes, the live announcements and the hold
between stages all survive — only the travel goes. This matters more here than
in most patterns: the animation is the message, so the reduced version has to
keep delivering that message in text rather than simply removing it.
The card is dark in both themes. That is the design rather than an unfinished light mode, so its interior uses fixed light values instead of the inverting content tokens, which would resolve to grey-on-black in light mode. A hairline ring separates it from a dark canvas.
Performance
The sequence is one GSAP timeline per stage, scoped to the container through
useGSAP, so everything it creates is reverted on unmount.
The auto-advance is a gsap.delayedCall rather than a setTimeout. That is
not a style preference: the delayed call is registered with the scoped context,
so unmounting mid-sequence kills it, where a bare timeout fires into a
component that no longer exists. The original left one running.
The puck's travel animates left, which is a layout property — kept from the
original, because switching to a transform would need the track measured and
the pattern is not paying for that complexity for one 20px dot. It also carried
willChange: "transform" while animating left, which is wrong twice over:
permanent will-change holds a compositor layer for the page's whole life, and
it named a property that was never animating. That is gone.
The four progress bars animate scaleX from a left origin, which is composited
and free.
Source
The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.
"use client";
import { useRef, useState } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/cn";
import { HugeiconsBicycle } from "@/components/icons/IconBicycle";
import { HugeiconsPan03 } from "@/components/icons/IconPan";
import { HugeiconsPackageProcess } from "@/components/icons/PackageProcess";
import { HugeiconsSafeDelivery01 } from "@/components/icons/IconSafeDelivery";
gsap.registerPlugin(useGSAP);
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export interface OrderStage {
id: string;
title: string;
subtitle: string;
/** The small line under the subtitle, e.g. "Est. 10–15 min". */
meta: string;
icon: React.ReactNode;
/** Where this stage sits on the track, 0–1. */
progress: number;
}
export interface OrderStatusProps extends React.ComponentPropsWithoutRef<"div"> {
/** Defaults to the four-stage food delivery flow below. */
stages?: readonly OrderStage[];
/** Milliseconds to hold each stage before advancing. @default 1100 */
holdMs?: number;
onComplete?: () => void;
}
/* -------------------------------------------------------------------------- */
/* Default content */
/* -------------------------------------------------------------------------- */
export const defaultOrderStages: readonly OrderStage[] = [
{
id: "preparing",
title: "Preparing Your Order",
subtitle: "Restaurant is cooking your food",
meta: "Est. 10–15 min",
icon: <HugeiconsPan03 className="size-4" />,
progress: 0.25,
},
{
id: "packing",
title: "Packing & Quality Check",
subtitle: "Your order is being packed and sealed",
meta: "Est. 5 min",
icon: <HugeiconsPackageProcess className="size-4" />,
progress: 0.5,
},
{
id: "delivering",
title: "Out for Delivery",
subtitle: "Rider is on the way to your location",
meta: "Arriving soon",
icon: <HugeiconsBicycle className="size-4" />,
progress: 0.75,
},
{
id: "delivered",
title: "Delivered",
subtitle: "Order has arrived, enjoy your meal!",
meta: "Rate Restaurant",
icon: <HugeiconsSafeDelivery01 className="size-4" />,
progress: 1,
},
];
/* -------------------------------------------------------------------------- */
/* Motion — GSAP timings kept verbatim from the original */
/* -------------------------------------------------------------------------- */
const SEGMENTS = 4;
/** The travelling puck is 28px wide, so its left edge trails the mark by 14. */
const PUCK_OFFSET = 14;
/**
* How full each of the four bars should be at a given overall progress. Each
* bar owns a quarter of the track, so it fills across its own quarter and then
* stays full while the next one starts.
*/
function segmentScales(progress: number): number[] {
return Array.from({ length: SEGMENTS }, (_, i) =>
Math.max(0, Math.min((progress - i * 0.25) * SEGMENTS, 1)),
);
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
/**
* A delivery card that walks itself through the stages of an order.
*
* A status that only ever renders its current value makes people re-read the
* whole card to work out whether anything moved. Animating the change carries
* that information in the motion itself — the puck advances, so progress is
* legible before a single word has been read.
*/
export function OrderStatus({
stages = defaultOrderStages,
holdMs = 1100,
onComplete,
className,
...rest
}: OrderStatusProps) {
const reduce = useReducedMotion();
/* Scales every duration in the timeline. Reduced motion keeps the sequence
and the stage changes, and removes only the travel. */
const D = reduce ? 0 : 1;
const container = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(0);
const [puckVisible, setPuckVisible] = useState(true);
const badgeRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const subtitleRef = useRef<HTMLParagraphElement>(null);
const metaRef = useRef<HTMLParagraphElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const puckRef = useRef<HTMLDivElement>(null);
const stage = stages[index];
// Declared above the useGSAP call that closes over it, so the reference is
// never made against a binding that has not been initialised yet.
function advance(): void {
const next = index + 1;
const nextStage = stages[next];
if (!nextStage) return;
const text = [titleRef.current, subtitleRef.current, metaRef.current];
const tl = gsap.timeline();
tl.to(puckRef.current, {
scale: 0,
duration: 0.2 * D,
ease: "back.in(1.7)",
});
tl.to(
text,
{ opacity: 0, y: 9, duration: 0.2 * D, ease: "elastic.out(1, 0.4)" },
`-=${0.2 * D}`,
);
tl.call(() => setIndex(next));
tl.to(
puckRef.current,
{ scale: 1, duration: 0.2 * D, ease: "back.out(1.7)" },
`+=${0.1 * D}`,
);
tl.to(
text,
{
opacity: 1,
y: 0,
duration: 0.4 * D,
stagger: 0.1 * D,
ease: "elastic.out(1, 0.4)",
},
`-=${0.2 * D}`,
);
tl.fromTo(
puckRef.current,
{ left: `calc(${stage.progress * 100}% - ${PUCK_OFFSET}px)` },
{
left: `calc(${nextStage.progress * 100}% - ${PUCK_OFFSET}px)`,
duration: 0.6 * D,
ease: "power2.inOut",
},
`-=${0.2 * D}`,
);
const bars = trackRef.current?.querySelectorAll(".order-status-segment");
segmentScales(nextStage.progress).forEach((scaleX, i) => {
if (bars?.[i]) {
tl.to(
bars[i],
{ scaleX, duration: 0.6 * D, ease: "power2.inOut" },
"<",
);
}
});
// The puck retires at the last stage: the journey is over, so a marker
// still sitting on the track would suggest it is not.
if (next === stages.length - 1) {
tl.to(
puckRef.current,
{
scale: 0,
opacity: 0,
duration: 0.8 * D,
ease: "power2.inOut",
delay: 0.2 * D,
},
"<",
).call(() => {
setPuckVisible(false);
onComplete?.();
});
}
}
useGSAP(
() => {
/*
* `gsap.delayedCall` rather than `setTimeout`: it is registered with the
* scoped context, so unmounting mid-sequence kills it. A bare timeout
* would fire into a component that no longer exists.
*/
const tl = gsap.timeline({
onComplete: () => {
if (index < stages.length - 1)
gsap.delayedCall(holdMs / 1000, advance);
},
});
if (index !== 0) return;
tl.from(badgeRef.current, {
scale: 0,
duration: 0.6 * D,
ease: "back.out(1.7)",
});
tl.from(
[titleRef.current, subtitleRef.current, metaRef.current],
{
opacity: 0,
y: 20,
duration: 0.8 * D,
stagger: 0.1 * D,
ease: "elastic.out(1, 0.4)",
},
`-=${0.2 * D}`,
);
tl.from(
trackRef.current,
{ opacity: 0, duration: 0.3 * D, ease: "power2.out" },
`-=${0.1 * D}`,
);
tl.set(puckRef.current, {
delay: 0.2 * D,
left: `calc(0% - ${PUCK_OFFSET}px)`,
opacity: 1,
});
const bars = trackRef.current?.querySelectorAll(".order-status-segment");
segmentScales(stages[0].progress).forEach((scaleX, i) => {
if (bars?.[i]) {
tl.fromTo(
bars[i],
{ scaleX: 0 },
{ scaleX, duration: 0.6 * D, ease: "power2.out" },
"<",
);
}
});
tl.to(
puckRef.current,
{
left: `calc(${stages[0].progress * 100}% - ${PUCK_OFFSET}px)`,
duration: 0.6 * D,
ease: "power2.out",
},
"<",
);
},
{ scope: container, dependencies: [index] },
);
if (stages.length === 0) return null;
return (
<div
ref={container}
className={cn("flex w-full min-w-0 justify-center p-4", className)}
{...rest}
>
{/*
Deliberately dark in BOTH themes — the dark card is the design, not a
light-mode default. So its interior uses fixed light values rather than
the inverting content tokens, which would drop to #6c757d on black in
light mode. The hairline ring keeps it separated from the dark canvas.
*/}
<div className="w-full max-w-sm rounded-3xl bg-black p-8 ring-1 ring-white/10">
<div className="mb-6 flex items-start gap-4">
<div
ref={badgeRef}
className="shrink-0 rounded-2xl bg-zinc-900 p-1.5"
>
<motion.svg
aria-hidden="true"
initial={reduce ? false : { scale: 0, opacity: 0 }}
animate={
reduce
? { opacity: 1, scale: 1 }
: { opacity: 1, scale: [0, 1.2, 1] }
}
transition={
reduce
? { duration: 0.01 }
: {
delay: 0.2,
scale: {
times: [0, 0.5, 1],
duration: 1.2,
ease: "easeInOut",
},
}
}
width="60"
height="60"
viewBox="0 0 48 48"
fill="#9f9fa9"
>
<ellipse cx="24" cy="30" rx="10" ry="8" />
<circle cx="14" cy="16" r="4" fill="white" />
<circle cx="24" cy="14" r="5" fill="white" />
<circle cx="34" cy="16" r="4" fill="white" />
<circle cx="38" cy="26" r="3" fill="white" />
</motion.svg>
</div>
{/*
The card advances on its own, so a screen reader user gets no event
to react to. The live region is the only thing that tells them the
order moved on.
*/}
<div className="flex-1" role="status" aria-live="polite">
<h2 ref={titleRef} className="mb-0.5 text-base text-white">
{stage.title}
</h2>
<p ref={subtitleRef} className="mb-1 text-xs text-white/60">
{stage.subtitle}
</p>
<p ref={metaRef} className="text-sm text-white">
{stage.meta}
</p>
</div>
</div>
<div
ref={trackRef}
className="relative rounded-2xl bg-zinc-900 px-2 py-6"
>
<div
className="relative h-1"
role="progressbar"
aria-valuemin={0}
aria-valuemax={stages.length}
aria-valuenow={index + 1}
aria-valuetext={`${stage.title} — step ${index + 1} of ${stages.length}`}
>
<div className="absolute inset-0 flex gap-2">
{Array.from({ length: SEGMENTS }).map((_, i) => (
<div key={i} className="h-1 flex-1 rounded-full bg-zinc-700" />
))}
</div>
<div className="absolute inset-0 flex gap-2">
{Array.from({ length: SEGMENTS }).map((_, i) => (
<div
key={i}
className="order-status-segment my-auto h-1 flex-1 origin-left rounded-full bg-lime-400"
/>
))}
</div>
{puckVisible && (
<div
ref={puckRef}
aria-hidden="true"
className="absolute top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded-full bg-lime-400"
>
{stage.icon}
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default OrderStatus;
Technologies
| Framework | React 19 |
| Motion | GSAP 3 (@gsap/react useGSAP) for the sequence, Motion 12 for the badge |
| Styling | Tailwind CSS v4 |
| Types | TypeScript 5.9, strict |
Credits
Original design by
tanjim38. Renamed from Food
Order Card, which described the example rather than the job. Rebuilt with a
small props API, a live region, a progressbar contract, reduced-motion support
and a delayed call that cleans up after itself. Every GSAP duration, ease and
position offset — including the elastic.out(1, 0.4) on the text and the
back.in/back.out(1.7) on the puck — is unchanged.