Order Status

A status that only ever renders its current value makes people re-read the whole card to work out whether anything actually moved since they last looked.

FeedbackCommerceDashboardadvanced
  • Touch
  • Responsive
  • Reduced motion

Preparing Your Order

Restaurant is cooking your food

Est. 10–15 min

npx shadcn@latest add https://design-recreation.vercel.app/r/order-status.json

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

bash
npm install gsap @gsap/react motion

Then copy OrderStatus.tsx. It depends on @/lib/cn and the four icons in @/components/icons, or pass your own through stages.

Usage

tsx
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.

tsx
<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.

PropTypeDefaultDescription
stagesreadonly OrderStage[]the delivery flowOrdered, forward-only. Empty renders nothing.
holdMsnumber1100How long each stage is held before advancing.
onComplete() => void—Fires once, when the last stage is reached.
classNamestring—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.

OrderStatus.tsx
"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

FrameworkReact 19
MotionGSAP 3 (@gsap/react useGSAP) for the sequence, Motion 12 for the badge
StylingTailwind CSS v4
TypesTypeScript 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.