A delivery status card with a hand drawn SVG city map on one side and the order details on the other. The route sketches itself in, then a courier marker rides it street by street, turning at each corner, with a name tag and minutes left floating above it. The road behind it darkens as it goes. Beside the map a large countdown flips minute by minute, a line runs down a five step timeline in time with the trip, and the last step ticks itself off when the marker reaches the door. Then the trip starts again. On phones the map sits on top and the details follow.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from "framer-motion";
const order = {
number: "4821",
shop: "Greenway Deli",
summary: "3 items · $27.40",
courier: { name: "Imani Okafor", initials: "IO", detail: "Cargo bike · 4.9 rating" },
minutes: 14, // length of the whole trip, used for the countdown
arrival: "18:36",
address: "Flat 3, 18 Alder Street",
note: "Leave with the concierge if no answer",
};
const steps = [
{ title: "Order confirmed", detail: "18:02" },
{ title: "Packed at Greenway Deli", detail: "18:09" },
{ title: "On the way", detail: "Imani has your order" },
{ title: "Around the corner", detail: "Head to the door" },
{ title: "Delivered", detail: "Enjoy" },
];
/** The route follows the street grid drawn below. Edit the two together. */
const ROUTE = "M120 400V342Q120 330 132 330H268Q280 330 280 318V202Q280 190 292 190H428Q440 190 440 178V132Q440 120 452 120H520";
const TRIP_SECONDS = 11;
const START_AT = 0.16;
const NEARBY_AT = 0.78;
const streetsX = [-120, -40, 40, 120, 200, 280, 360, 440, 520, 600, 680, 760];
const streetsY = [-90, -20, 50, 120, 190, 260, 330, 400, 470, 540, 610];
const inPark = (x: number, y: number) => (x >= 360 && x < 520 && y >= 330 && y < 470) || (x >= 40 && x < 200 && y >= -20 && y < 120);
export default function OrderTrackingMapWithMovingCourierAndLiveTimeline() {
const reduce = useReducedMotion();
const card = useRef<HTMLDivElement>(null);
const route = useRef<SVGPathElement>(null);
const marker = useRef<SVGGElement>(null);
const arrow = useRef<SVGGElement>(null);
const visible = useInView(card, { amount: 0.25 });
const p = useMotionValue(reduce ? 0.62 : START_AT);
const [eta, setEta] = useState(Math.ceil((1 - (reduce ? 0.62 : START_AT)) * order.minutes));
const [active, setActive] = useState(2);
const [cycle, setCycle] = useState(0);
const fill = useTransform(p, (v) => (v < NEARBY_AT ? 2 + v / NEARBY_AT : 3 + (v - NEARBY_AT) / (1 - NEARBY_AT)) / 4);
const placeMarker = (v: number) => {
const path = route.current;
if (!path || !marker.current || !arrow.current) return;
const length = path.getTotalLength();
const at = path.getPointAtLength(v * length);
const ahead = path.getPointAtLength(Math.min(length, v * length + 3));
marker.current.setAttribute("transform", `translate(${at.x.toFixed(1)} ${at.y.toFixed(1)})`);
if (v < 0.995) arrow.current.setAttribute("transform", `rotate(${((Math.atan2(ahead.y - at.y, ahead.x - at.x) * 180) / Math.PI + 90).toFixed(1)})`);
};
useMotionValueEvent(p, "change", (v) => {
placeMarker(v);
const mins = Math.ceil((1 - v) * order.minutes);
setEta((e) => (e === mins ? e : mins));
const step = v >= 0.999 ? 4 : v >= NEARBY_AT ? 3 : 2;
setActive((a) => (a === step ? a : step));
});
useEffect(() => placeMarker(p.get()), []); // eslint-disable-line react-hooks/exhaustive-deps
// Ride the route, pause on the doorstep, then start the trip again.
useEffect(() => {
if (reduce || !visible) return;
let timer: number | undefined;
const ride = animate(p, 1, {
duration: Math.max(0.2, (1 - p.get()) * TRIP_SECONDS),
ease: "linear",
delay: cycle === 0 && p.get() === START_AT ? 0.6 : 0,
onComplete: () => {
timer = window.setTimeout(() => {
p.set(0.03);
setCycle((c) => c + 1);
}, 2600);
},
});
return () => {
ride.stop();
window.clearTimeout(timer);
};
}, [reduce, visible, cycle, p]);
const delivered = active === 4;
const arrival = order.arrival;
return (
<section className="bg-slate-100 px-4 py-14 sm:px-6 sm:py-16">
<style>{`
@keyframes order-tracking-map-halo { 0% { transform: scale(0.5); opacity: 0.7 } 100% { transform: scale(2.4); opacity: 0 } }
.order-tracking-map-halo { transform-box: fill-box; transform-origin: center; animation: order-tracking-map-halo 1.8s ease-out infinite; }
@media (prefers-reduced-motion: reduce) { .order-tracking-map-halo { animation: none; opacity: 0.25; } }
`}</style>
<div ref={card} className="mx-auto grid max-w-5xl overflow-hidden rounded-3xl bg-white shadow-xl shadow-slate-900/10 lg:grid-cols-[1.2fr_1fr]">
<div className="relative h-[380px] min-w-0 bg-[#eef1f5] sm:h-[420px] lg:h-auto lg:min-h-[600px]">
<svg viewBox="0 0 640 520" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 h-full w-full" role="img" aria-label={`Map of the delivery route from ${order.shop} to your address`}>
<rect x="-200" y="-200" width="1040" height="920" fill="#eef1f5" />
<path d="M-60 40C110 110 150 240 330 262S560 350 720 440" fill="none" stroke="#bfe0f6" strokeWidth="44" strokeLinecap="round" />
<g transform="rotate(-10 320 260)">
{streetsX.slice(0, -1).flatMap((x, i) =>
streetsY.slice(0, -1).map((y, j) =>
inPark(x, y) ? null : (
<g key={`${i}-${j}`} fill="#e1e6ed" opacity={0.55 + (((i * 7 + j * 3) % 3) * 0.2)}>
<rect x={x + 12} y={y + 12} width={(i + j) % 2 ? 24 : 34} height="19" rx="3" />
<rect x={x + ((i + j) % 2 ? 44 : 52)} y={y + 34} width="20" height="24" rx="3" />
</g>
),
),
)}
{streetsX.map((x) => (
<line key={x} x1={x} y1="-200" x2={x} y2="720" stroke="#fff" strokeWidth={x === 280 ? 17 : 10} />
))}
{streetsY.map((y) => (
<line key={y} x1="-200" y1={y} x2="840" y2={y} stroke="#fff" strokeWidth={y === 260 ? 17 : 10} />
))}
<rect x="366" y="336" width="148" height="128" rx="12" fill="#cfe9d3" />
<ellipse cx="470" cy="424" rx="24" ry="15" fill="#bfe0f6" />
<circle cx="398" cy="370" r="9" fill="#b4dbbb" />
<circle cx="420" cy="386" r="6" fill="#b4dbbb" />
<rect x="46" y="-14" width="148" height="128" rx="12" fill="#cfe9d3" />
<circle cx="150" cy="70" r="11" fill="#b4dbbb" />
<circle cx="84" cy="88" r="7" fill="#b4dbbb" />
<motion.path d={ROUTE} fill="none" stroke="#fda4af" strokeWidth="7" strokeLinecap="round" strokeLinejoin="round" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 1, ease: [0.6, 0, 0.2, 1] }} />
<motion.path ref={route} d={ROUTE} fill="none" stroke="#e11d48" strokeWidth="7" strokeLinecap="round" strokeLinejoin="round" style={{ pathLength: p }} />
<g transform="translate(120 400)">
<circle r="13" fill="#0f172a" stroke="#fff" strokeWidth="3" />
<path d="M-5.5 -1.5h11v6.5h-11zM-6.5 -1.5l1.5-4h10l1.5 4" fill="none" stroke="#fff" strokeWidth="1.7" strokeLinejoin="round" />
<g transform="rotate(10)">
<rect x="18" y="-11" width="92" height="22" rx="11" fill="#0f172a" />
<text x="64" y="4" textAnchor="middle" fontSize="11" fontWeight="600" fill="#fff" fontFamily="ui-sans-serif, system-ui, sans-serif">
{order.shop}
</text>
</g>
</g>
<g transform="translate(520 120)">
<circle r="13" fill="#e11d48" className="order-tracking-map-halo" />
<motion.g animate={delivered && !reduce ? { scale: [1, 1.45, 1] } : { scale: 1 }} transition={{ duration: 0.5, ease: "easeOut" }}>
<circle r="14" fill="#fff" stroke="#e11d48" strokeWidth="3.5" />
<path d="M-6 0l6-5.5 6 5.5M-4 -1v6h8v-6" fill="none" stroke="#e11d48" strokeWidth="1.9" strokeLinejoin="round" strokeLinecap="round" />
</motion.g>
<g transform="rotate(10)">
<rect x="-66" y="-11" width="46" height="22" rx="11" fill="#fff" />
<text x="-43" y="4" textAnchor="middle" fontSize="11" fontWeight="700" fill="#0f172a" fontFamily="ui-sans-serif, system-ui, sans-serif">
Home
</text>
</g>
</g>
<g ref={marker} transform="translate(164 330)">
<circle r="12" fill="#e11d48" className="order-tracking-map-halo" />
<circle r="13.5" fill="#fff" />
<circle r="10.5" fill="#e11d48" />
<g ref={arrow} transform="rotate(90)">
<path d="M0 -6.2L4.8 4.6L0 2.2L-4.8 4.6Z" fill="#fff" />
</g>
<g transform="rotate(10)">
<g transform="translate(0 -30)">
<rect x="-41" y="-12" width="82" height="24" rx="12" fill="#fff" stroke="#e2e8f0" />
<path d="M-5 11.5l5 6 5-6z" fill="#fff" />
<text y="4" textAnchor="middle" fontSize="11" fontWeight="700" fill="#0f172a" fontFamily="ui-sans-serif, system-ui, sans-serif">
{delivered ? "Here now" : `Imani · ${eta} min`}
</text>
</g>
</g>
</g>
</g>
</svg>
<p className="absolute left-4 top-4 flex items-center gap-2 rounded-full bg-white px-3 py-1.5 text-xs font-semibold text-slate-800 shadow-sm">
<span className="relative flex h-2 w-2">
<span className="absolute inset-0 animate-ping rounded-full bg-rose-500 opacity-70 motion-reduce:animate-none" />
<span className="relative h-2 w-2 rounded-full bg-rose-600" />
</span>
Live
</p>
</div>
<div className="flex min-w-0 flex-col p-5 sm:p-8">
<p className="text-sm font-semibold text-rose-700">
Order #{order.number} · {order.shop}
</p>
<h2 className="mt-2 flex h-12 items-baseline gap-2 overflow-hidden text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={delivered ? "done" : eta}
className="inline-block tabular-nums"
initial={reduce ? false : { y: 30, opacity: 0, filter: "blur(4px)" }}
animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { y: -30, opacity: 0, filter: "blur(4px)" }}
transition={{ type: "spring", stiffness: 300, damping: 26 }}
>
{delivered ? "Delivered" : eta}
</motion.span>
</AnimatePresence>
{!delivered && <span className="text-xl font-medium tracking-normal text-slate-600">min away</span>}
</h2>
<p className="mt-1 text-sm text-slate-600">{delivered ? `Handed over at ${arrival}. ${order.summary}` : `Arriving about ${arrival} · ${order.summary}`}</p>
<div className="mt-6 flex items-center gap-3 rounded-2xl bg-slate-50 p-3">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-rose-600 text-sm font-semibold text-white">{order.courier.initials}</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900">{order.courier.name}</p>
<p className="truncate text-sm text-slate-600">{order.courier.detail}</p>
</div>
<a href="#" aria-label={`Message ${order.courier.name}`} className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-slate-800 shadow-sm ring-1 ring-slate-200 hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-600">
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" aria-hidden="true">
<path d="M3 4.5h14v9H9l-4 3v-3H3z" />
</svg>
</a>
<a href="#" aria-label={`Call ${order.courier.name}`} className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-slate-800 shadow-sm ring-1 ring-slate-200 hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-600">
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" aria-hidden="true">
<path d="M5 3h3l1.5 4-2 1.5a9 9 0 0 0 4 4l1.5-2 4 1.5v3c0 1-1 2-2 2C8.5 17 3 11.5 3 5c0-1 1-2 2-2z" />
</svg>
</a>
</div>
<ol className="relative mt-7">
<span aria-hidden="true" className="absolute bottom-5 left-[11px] top-3 w-0.5 rounded-full bg-slate-200" />
<motion.span aria-hidden="true" style={{ scaleY: fill }} className="absolute bottom-5 left-[11px] top-3 w-0.5 origin-top rounded-full bg-rose-600" />
{steps.map((s, i) => {
const done = i < active || delivered;
const now = i === active && !delivered;
return (
<li key={s.title} aria-current={now ? "step" : undefined} className="relative flex gap-4 pb-5 last:pb-0">
<span className={`relative z-10 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 transition-colors duration-300 motion-reduce:transition-none ${done ? "border-rose-600 bg-rose-600" : now ? "border-rose-600 bg-white" : "border-slate-300 bg-white"}`}>
{done && (
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5 text-white" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<motion.path d="M4.5 10.5l3.5 3.5 7.5-8" initial={reduce || i < 2 ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.35 }} />
</svg>
)}
{now && <span className="h-2 w-2 animate-pulse rounded-full bg-rose-600 motion-reduce:animate-none" />}
</span>
<div className="-mt-0.5 flex min-w-0 flex-1 flex-wrap items-baseline justify-between gap-x-3">
<p className={`text-sm font-semibold ${done || now ? "text-slate-900" : "text-slate-600"}`}>{s.title}</p>
<p className="text-sm tabular-nums text-slate-600">{now && i === 2 ? `${eta} min left` : s.detail}</p>
</div>
</li>
);
})}
</ol>
<div className="mt-auto pt-7">
<div className="flex items-center gap-3 border-t border-slate-200 pt-5">
<svg viewBox="0 0 20 20" className="h-5 w-5 shrink-0 text-slate-500" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" aria-hidden="true">
<path d="M10 17.5s5.5-4.7 5.5-9a5.5 5.5 0 0 0-11 0c0 4.3 5.5 9 5.5 9z" />
<circle cx="10" cy="8.5" r="2" />
</svg>
<p className="min-w-0 flex-1 text-sm text-slate-600">
<span className="block font-semibold text-slate-900">{order.address}</span>
{order.note}
</p>
<a href="#" className="flex h-10 items-center rounded-full px-3 text-sm font-semibold text-rose-700 hover:bg-rose-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-600">
Change
</a>
</div>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/order-tracking-map-with-moving-courier-and-live-timeline.json
Progress is one motion value from 0 to 1. On every change the code asks the SVG path for the point at that share of its length with `getPointAtLength`, and writes it to the marker as a `translate`. A second point just ahead gives the heading for the arrow.
The route is drawn twice. The pale copy is always complete. The strong copy has its `pathLength` bound to the same progress value, so it ends exactly under the marker.
No. It is an inline SVG: white lines for streets on a tilted grid, rounded rectangles for parks and buildings and one wide stroke for the river. It needs no network request and can be restyled with fills.
Change the numbers in `streetsX` and `streetsY`, move the park rectangles, and rewrite `ROUTE` so it runs along your new streets. The tilt is the `rotate(-10 320 260)` on the group.
No. The ride is started only while the card is in view, and it resumes from where it stopped when the card returns.
Related: Self-Drawing Vertical Timeline with Pop-In Items, Money Transfer Status Card with Merging Rows, Checkout Payment Form with Order Summary.
More ecommerce or all free UI components. Start for free to have a full site built around it.
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.