Delivery Tracking Card with Truck Progress Line

A parcel tracking card in charcoal and amber. The top band shows the expected arrival time in large numerals with the item and order number. Below, a thin line runs from dispatch to delivery, and a round truck badge glides along it as the parcel moves through four stages: processing, in transit, out for delivery and delivered. The status word, the last known place and the time since the last scan change with each stage, and on delivery the line and badge turn green. A strip of parcel tape with a cut end decorates the right edge. The card steps through its stages on its own until a stage is pressed, and fits a 360 pixel phone.

Code

// Adapted from Animata (https://animata.design/docs/widget/delivery-card)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { MapPin, Truck } from "lucide-react";

const stages = [
  { progress: 0, status: "Processing", headline: "Arrives today", place: "Packing bench, Leeds", ago: "Just now" },
  { progress: 34, status: "In transit", headline: "Arrives today", place: "Sheffield depot", ago: "42 min ago" },
  { progress: 68, status: "Out for delivery", headline: "Arrives today", place: "Nottingham, 9 stops away", ago: "6 min ago" },
  { progress: 100, status: "Delivered", headline: "Arrived", place: "Left in the porch", ago: "1 min ago" },
];
const order = { number: "KW-48213", item: "Walnut desk lamp", eta: "14:26" };
const STEP_MS = 2200; // demo only: time spent on each stage

export default function DeliveryTrackingCardWithTruckProgressLine() {
  const [stageIndex, setStageIndex] = useState(1);
  const [auto, setAuto] = useState(true);
  const stage = stages[stageIndex];
  const delivered = stage.progress >= 100;

  useEffect(() => {
    if (!auto) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const timer = window.setInterval(() => setStageIndex((current) => (current + 1) % stages.length), STEP_MS);
    return () => window.clearInterval(timer);
  }, [auto]);

  const slide = "transition-transform duration-700 ease-[cubic-bezier(0.65,0,0.35,1)] motion-reduce:transition-none";

  return (
    <div className="bg-amber-50 px-4 py-14 sm:py-20">
      <div className="relative mx-auto w-full max-w-sm overflow-hidden rounded-[2rem] bg-zinc-800 text-white shadow-2xl shadow-zinc-900/30">
        {/* Parcel tape running down the right edge, cut to a point. */}
        <div className="absolute right-6 top-0 z-10 h-28 w-6 bg-white/25" aria-hidden="true">
          <div className="absolute -bottom-3 left-0 h-6 w-6 rotate-45 bg-zinc-600" />
        </div>

        <div className="bg-zinc-600 px-6 pb-4 pt-6">
          <p className="text-sm font-medium text-zinc-100">{stage.headline}</p>
          <p className="mt-1 text-5xl font-light tabular-nums tracking-tight">{order.eta}</p>
          <p className="mt-2 text-sm text-zinc-200">
            {order.item}, order {order.number}
          </p>
        </div>

        <div className="border-t-2 border-zinc-900 bg-zinc-700 px-6 pb-6 pt-4">
          <p className={`text-sm font-semibold ${delivered ? "text-emerald-300" : "text-amber-300"}`} aria-live="polite">
            {stage.status}
          </p>

          {/* The line. The fill scales from the left and the truck rides a full-width rail that slides with it. */}
          <div
            role="progressbar"
            aria-label="Delivery progress"
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={stage.progress}
            className="relative mx-4 mb-7 mt-7 h-0.5 bg-zinc-500"
          >
            <div className={`absolute inset-0 origin-left ${delivered ? "bg-emerald-300" : "bg-amber-300"} ${slide}`} style={{ transform: `scaleX(${stage.progress / 100})` }} />
            <span className="absolute left-0 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-300" aria-hidden="true" />
            <span className={`absolute right-0 top-1/2 h-3 w-3 -translate-y-1/2 translate-x-1/2 rounded-full transition-colors duration-500 ${delivered ? "bg-emerald-300" : "bg-zinc-500"}`} aria-hidden="true" />
            <div className={`absolute inset-0 ${slide}`} style={{ transform: `translateX(${stage.progress}%)` }} aria-hidden="true">
              <span className={`absolute left-0 top-1/2 flex h-9 w-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full transition-colors duration-500 ${delivered ? "bg-emerald-300 text-emerald-950" : "bg-amber-300 text-amber-950"}`}>
                <Truck size={18} strokeWidth={2} />
              </span>
            </div>
          </div>

          <ol className="grid grid-cols-4 gap-1">
            {stages.map((item, index) => (
              <li key={item.status}>
                <button
                  type="button"
                  aria-current={index === stageIndex ? "step" : undefined}
                  onClick={() => {
                    setAuto(false);
                    setStageIndex(index);
                  }}
                  className={`flex h-10 w-full items-center justify-center rounded-lg px-1 text-center text-[11px] font-medium leading-tight transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 ${
                    index === stageIndex ? "bg-zinc-900 text-white" : index < stageIndex ? "text-zinc-100 hover:bg-zinc-600" : "text-zinc-300 hover:bg-zinc-600"
                  }`}
                >
                  {item.status}
                </button>
              </li>
            ))}
          </ol>

          <div className="mt-5 flex items-start gap-2 border-t border-zinc-600 pt-4">
            <MapPin size={18} className="mt-0.5 shrink-0 text-zinc-300" aria-hidden="true" />
            <div className="min-w-0">
              <p className="truncate text-base font-semibold">{stage.place}</p>
              <p className="text-sm tabular-nums text-zinc-300">{stage.ago}</p>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/delivery-tracking-card-with-truck-progress-line.json

When to use it

How to customize it

Accessibility

Questions

How does the truck move without animating layout?

The truck sits at the left edge of an invisible rail the same width as the line. Translating that rail by the progress percentage moves the truck by that share of the line's width. The fill uses `scaleX` from the left edge. Both are transforms, so the browser does not recalculate layout.

How do I drive it from real tracking data?

Delete the interval and set `stageIndex` from your courier's status, or drop the stages and pass any number from 0 to 100 as the progress. The status, place and time are plain text you can fill from the latest scan event.

Why are the stage labels buttons?

In the demo they let a visitor jump to any stage and see the card in that state. In a real tracker, where the customer cannot change the stage, render them as plain list items and keep `aria-current` on the active one.

Can there be more than four stages?

Yes. Add entries to `stages` and change `grid-cols-4` to match. With six or more, shorten the labels or hide them on small screens so they do not wrap.

What is the strip on the right edge?

It is decoration meant to look like parcel tape. It is one translucent bar with a rotated square at its end in the colour of the band behind it, which cuts the end into a point.

Adapted from Delivery Card in Animata, used under its open-source licence. Licences and credits

Related: Money Transfer Status Card with Merging Rows, Cart Drawer with Quantity Steppers, Checkout Payment Form with Order Summary.

More ecommerce or all free UI components. Start for free to have a full site built around it.

More in Ecommerce

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap