Web InnoventixFreeCode

Map Delivery Tracking

Original · free

delivery tracking with route

byWeb InnoventixReact + Tailwind
mapdeliverytrackingmaps
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/map-delivery-tracking.json
map-delivery-tracking.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type Stop = {
  id: string;
  t: number;
  title: string;
  detail: string;
};

type Sample = { x: number; y: number };
type Point = { x: number; y: number; angle: number };

const ROUTE_D =
  "M126 370 L126 296 Q126 280 142 280 L222 280 Q238 280 238 264 L238 206 Q238 190 254 190 L334 190 Q350 190 350 174 L350 116 Q350 100 366 100 L574 100";

const PICKUP = { x: 126, y: 370 };
const DROPOFF = { x: 574, y: 100 };

const START_MIN = 8 * 60 + 41;
const TRIP_MIN = 91;
const TRIP_KM = 6.4;

const BLOCK_X = [24, 136, 248, 360, 472];
const BLOCK_Y = [20, 110, 200, 290];
const PARK = { x: 360, y: 110 };

const BLOCKS: Array<{ x: number; y: number }> = BLOCK_X.flatMap((x) =>
  BLOCK_Y.filter((y) => !(x === PARK.x && y === PARK.y)).map((y) => ({ x, y })),
);

const STOPS: Stop[] = [
  { id: "picked", t: 0, title: "Picked up", detail: "Kembali Roastery — 12 Jln Sulaiman" },
  { id: "depot", t: 0.32, title: "Scanned at North Hub", detail: "Bay 4 — sorted to bike route 17" },
  { id: "out", t: 0.5, title: "Out for delivery", detail: "Priya N. took the parcel at Bay 4" },
  { id: "near", t: 0.86, title: "Three stops away", detail: "Turning onto Arcadia Boulevard" },
  { id: "done", t: 1, title: "Delivered", detail: "Unit 14-02, Arcadia Tower — hand to resident" },
];

function sampleAt(samples: Sample[], u: number): Sample {
  if (samples.length === 0) return PICKUP;
  const f = Math.min(1, Math.max(0, u)) * (samples.length - 1);
  const i = Math.floor(f);
  const j = Math.min(samples.length - 1, i + 1);
  const k = f - i;
  return {
    x: samples[i].x + (samples[j].x - samples[i].x) * k,
    y: samples[i].y + (samples[j].y - samples[i].y) * k,
  };
}

function fmtTime(totalMin: number): string {
  const m = Math.round(totalMin);
  const h = Math.floor(m / 60) % 24;
  const mm = ((m % 60) + 60) % 60;
  return `${String(h).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
}

export default function MapDeliveryTracking() {
  const reduce = useReducedMotion();
  const pathRef = useRef<SVGPathElement | null>(null);
  const startFromRef = useRef(0);
  const [len, setLen] = useState(0);
  const [samples, setSamples] = useState<Sample[]>([]);
  const [progress, setProgress] = useState(0.62);
  const [playing, setPlaying] = useState(false);
  const [notify, setNotify] = useState(true);

  useEffect(() => {
    const p = pathRef.current;
    if (!p) return;
    const total = p.getTotalLength();
    const steps = 360;
    const next: Sample[] = [];
    for (let i = 0; i <= steps; i += 1) {
      const pt = p.getPointAtLength((i / steps) * total);
      next.push({ x: pt.x, y: pt.y });
    }
    setLen(total);
    setSamples(next);
  }, []);

  useEffect(() => {
    if (!playing) return;
    let raf = 0;
    const from = startFromRef.current;
    const t0 = performance.now();
    const tick = (now: number) => {
      const p = Math.min(1, from + (now - t0) / 14000);
      setProgress(p);
      if (p >= 1) {
        setPlaying(false);
        return;
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [playing]);

  const togglePlay = () => {
    if (playing) {
      setPlaying(false);
      return;
    }
    if (reduce) {
      setProgress(1);
      return;
    }
    const from = progress >= 1 ? 0 : progress;
    startFromRef.current = from;
    setProgress(from);
    setPlaying(true);
  };

  const courier = useMemo<Point>(() => {
    if (samples.length === 0) return { x: PICKUP.x, y: PICKUP.y, angle: -90 };
    const at = sampleAt(samples, progress);
    const behind = sampleAt(samples, progress - 0.005);
    const ahead = sampleAt(samples, progress + 0.005);
    const angle = (Math.atan2(ahead.y - behind.y, ahead.x - behind.x) * 180) / Math.PI;
    return { x: at.x, y: at.y, angle };
  }, [progress, samples]);

  const stopPoints = useMemo(() => {
    if (samples.length === 0) return null;
    return STOPS.map((s) => {
      const pt = sampleAt(samples, s.t);
      return { id: s.id, t: s.t, x: pt.x, y: pt.y };
    });
  }, [samples]);

  const reached = STOPS.filter((s) => s.t <= progress + 0.001);
  const current = reached.length > 0 ? reached[reached.length - 1] : STOPS[0];
  const minsAway = Math.max(0, Math.round((1 - progress) * TRIP_MIN));
  const kmAway = (1 - progress) * TRIP_KM;
  const pct = Math.round(progress * 100);
  const arrival = fmtTime(START_MIN + TRIP_MIN);
  const delivered = progress >= 1;

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes mdt-ping {
          0% { transform: scale(0.45); opacity: 0.7; }
          70%, 100% { transform: scale(1.75); opacity: 0; }
        }
        @keyframes mdt-dash {
          to { stroke-dashoffset: -22; }
        }
        @keyframes mdt-glow {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.35; transform: scale(0.72); }
        }
        .mdt-ping {
          transform-box: fill-box;
          transform-origin: center;
          animation: mdt-ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite;
        }
        .mdt-flow {
          animation: mdt-dash 1.1s linear infinite;
        }
        .mdt-glow {
          animation: mdt-glow 1.8s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .mdt-ping, .mdt-flow, .mdt-glow { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <motion.header
          initial={reduce ? false : { opacity: 0, y: 14 }}
          whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-80px" }}
          transition={{ duration: 0.5, ease: "easeOut" }}
          className="mb-8 max-w-2xl sm:mb-10"
        >
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 dark:border-indigo-900 dark:bg-indigo-950/60 dark:text-indigo-300">
            <span className="mdt-glow h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Live tracking
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Order KR-4821 is on the way
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Two bags of Sumatra Lintong and a hand grinder, riding across town from the roastery to
            Arcadia Tower. Scrub the route to see where the courier has been.
          </p>
        </motion.header>

        <div className="grid gap-6 lg:grid-cols-[1.55fr_1fr]">
          <motion.div
            initial={reduce ? false : { opacity: 0, y: 18 }}
            whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
            viewport={{ once: true, margin: "-80px" }}
            transition={{ duration: 0.55, ease: "easeOut" }}
            className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm lg:self-start dark:border-slate-800 dark:bg-slate-900/50"
          >
            <div className="relative">
              <svg
                viewBox="0 0 600 400"
                role="img"
                aria-label="Street map of the delivery route from Kembali Roastery to Arcadia Tower, with the courier's position marked along the way."
                className="block h-auto w-full"
              >
                <rect x="0" y="0" width="600" height="400" className="fill-white dark:fill-slate-900" />

                {BLOCKS.map((b) => (
                  <rect
                    key={`${b.x}-${b.y}`}
                    x={b.x}
                    y={b.y}
                    width="92"
                    height="70"
                    rx="4"
                    className="fill-slate-100 dark:fill-slate-800/80"
                  />
                ))}

                <rect
                  x={PARK.x}
                  y={PARK.y}
                  width="92"
                  height="70"
                  rx="4"
                  className="fill-emerald-100 dark:fill-emerald-950"
                />
                <circle cx="406" cy="145" r="14" className="fill-emerald-200/70 dark:fill-emerald-900/70" />

                <path
                  d="M600 232 C 548 264, 524 320, 476 400 L600 400 Z"
                  className="fill-sky-100 dark:fill-sky-950"
                />

                <g className="fill-slate-400 dark:fill-slate-500" fontSize="8" fontWeight="600" letterSpacing="0.6">
                  <text x="70" y="318" textAnchor="middle">KEMBALI</text>
                  <text x="70" y="328" textAnchor="middle">ROASTERY</text>
                  <text x="294" y="232" textAnchor="middle">NORTH HUB</text>
                  <text x="294" y="242" textAnchor="middle">BAY 4</text>
                  <text x="406" y="180" textAnchor="middle">SELAT PARK</text>
                  <text x="518" y="52" textAnchor="middle">ARCADIA</text>
                  <text x="518" y="62" textAnchor="middle">TOWER</text>
                  <text x="546" y="330" textAnchor="middle" transform="rotate(58 546 330)">
                    KELANA RIVER
                  </text>
                </g>

                <path
                  ref={pathRef}
                  d={ROUTE_D}
                  fill="none"
                  strokeWidth="7"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="stroke-slate-200 dark:stroke-slate-700"
                />
                <path
                  d={ROUTE_D}
                  fill="none"
                  strokeWidth="3"
                  strokeLinecap="round"
                  strokeDasharray="2 9"
                  className="mdt-flow stroke-slate-400 dark:stroke-slate-500"
                />

                {len > 0 && (
                  <>
                    <path
                      d={ROUTE_D}
                      fill="none"
                      strokeWidth="13"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeDasharray={len}
                      strokeDashoffset={len * (1 - progress)}
                      className="stroke-indigo-500/15 dark:stroke-indigo-400/15"
                    />
                    <path
                      d={ROUTE_D}
                      fill="none"
                      strokeWidth="5"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeDasharray={len}
                      strokeDashoffset={len * (1 - progress)}
                      className="stroke-indigo-500 dark:stroke-indigo-400"
                    />
                  </>
                )}

                {stopPoints
                  ?.filter((s) => s.t > 0 && s.t < 1)
                  .map((s) => (
                    <circle
                      key={s.id}
                      cx={s.x}
                      cy={s.y}
                      r="4.5"
                      strokeWidth="2.5"
                      className={
                        s.t <= progress + 0.001
                          ? "fill-white stroke-indigo-500 dark:fill-slate-900 dark:stroke-indigo-400"
                          : "fill-white stroke-slate-300 dark:fill-slate-900 dark:stroke-slate-600"
                      }
                    />
                  ))}

                <g transform={`translate(${PICKUP.x} ${PICKUP.y})`}>
                  <ellipse cx="0" cy="1" rx="7" ry="2.5" className="fill-slate-900/15 dark:fill-black/40" />
                  <path
                    d="M0 0 c-6 -8 -9.5 -12.5 -9.5 -17.5 a9.5 9.5 0 1 1 19 0 c0 5 -3.5 9.5 -9.5 17.5z"
                    className="fill-emerald-500 stroke-white dark:stroke-slate-900"
                    strokeWidth="1.5"
                  />
                  <circle cx="0" cy="-17.5" r="3.6" className="fill-white dark:fill-slate-900" />
                </g>

                <g transform={`translate(${DROPOFF.x} ${DROPOFF.y})`}>
                  <ellipse cx="0" cy="1" rx="7" ry="2.5" className="fill-slate-900/15 dark:fill-black/40" />
                  <path
                    d="M0 0 c-6 -8 -9.5 -12.5 -9.5 -17.5 a9.5 9.5 0 1 1 19 0 c0 5 -3.5 9.5 -9.5 17.5z"
                    className={
                      delivered
                        ? "fill-emerald-500 stroke-white dark:stroke-slate-900"
                        : "fill-rose-500 stroke-white dark:stroke-slate-900"
                    }
                    strokeWidth="1.5"
                  />
                  <circle cx="0" cy="-17.5" r="3.6" className="fill-white dark:fill-slate-900" />
                </g>

                <g transform={`translate(${courier.x} ${courier.y})`}>
                  <circle r="10" className="mdt-ping fill-indigo-500/40 dark:fill-indigo-400/40" />
                  <circle
                    r="10.5"
                    strokeWidth="2.5"
                    className="fill-white stroke-indigo-600 dark:fill-slate-900 dark:stroke-indigo-400"
                  />
                  <g transform={`rotate(${courier.angle})`}>
                    <path
                      d="M-3.4 -4.4 L5 0 L-3.4 4.4 L-1.4 0 Z"
                      className="fill-indigo-600 dark:fill-indigo-400"
                    />
                  </g>
                </g>
              </svg>

              <div className="pointer-events-none absolute left-3 top-3 flex items-center gap-2.5 rounded-xl border border-slate-200 bg-white/90 px-2.5 py-2 shadow-sm backdrop-blur-sm sm:left-4 sm:top-4 dark:border-slate-700 dark:bg-slate-900/90">
                <span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-indigo-600 text-xs font-semibold text-white">
                  PN
                </span>
                <span className="leading-tight">
                  <span className="block text-xs font-semibold text-slate-900 dark:text-white">
                    Priya N.
                  </span>
                  <span className="block text-[11px] text-slate-500 dark:text-slate-400">
                    Cargo bike · {kmAway.toFixed(1)} km to go
                  </span>
                </span>
              </div>
            </div>

            <div className="flex items-center gap-3 border-t border-slate-200 bg-slate-50/80 px-3 py-3 sm:px-4 dark:border-slate-800 dark:bg-slate-900/60">
              <button
                type="button"
                aria-pressed={playing}
                onClick={togglePlay}
                className={`inline-flex shrink-0 items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-2 text-xs font-medium text-white transition-colors hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 ${focusRing}`}
              >
                {playing ? (
                  <svg viewBox="0 0 16 16" aria-hidden="true" className="h-3.5 w-3.5 fill-current">
                    <rect x="3.5" y="2.5" width="3" height="11" rx="1" />
                    <rect x="9.5" y="2.5" width="3" height="11" rx="1" />
                  </svg>
                ) : (
                  <svg viewBox="0 0 16 16" aria-hidden="true" className="h-3.5 w-3.5 fill-current">
                    <path d="M4.5 2.8a1 1 0 0 1 1.52-.85l7.2 4.35a1 1 0 0 1 0 1.71l-7.2 4.35a1 1 0 0 1-1.52-.86z" />
                  </svg>
                )}
                {playing ? "Pause" : "Replay route"}
              </button>

              <label htmlFor="mdt-scrub" className="sr-only">
                Courier position along the route
              </label>
              <input
                id="mdt-scrub"
                type="range"
                min={0}
                max={100}
                step={1}
                value={pct}
                aria-valuetext={
                  delivered ? "Route complete, parcel delivered" : `${pct}% of route, ${minsAway} minutes away`
                }
                onChange={(e) => {
                  setPlaying(false);
                  setProgress(Number(e.target.value) / 100);
                }}
                className={`h-1.5 w-full flex-1 cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600 dark:bg-slate-700 dark:accent-indigo-400 ${focusRing}`}
              />

              <span className="w-10 shrink-0 text-right text-xs font-medium tabular-nums text-slate-500 dark:text-slate-400">
                {pct}%
              </span>
            </div>
          </motion.div>

          <motion.div
            initial={reduce ? false : { opacity: 0, y: 18 }}
            whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
            viewport={{ once: true, margin: "-80px" }}
            transition={{ duration: 0.55, delay: 0.08, ease: "easeOut" }}
            className="flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900/50"
          >
            <div role="status" className="border-b border-slate-200 pb-5 dark:border-slate-800">
              {delivered ? (
                <p className="text-2xl font-semibold tracking-tight text-emerald-600 dark:text-emerald-400">
                  Delivered at {arrival}
                </p>
              ) : (
                <p className="flex items-baseline gap-1.5">
                  <span className="text-4xl font-semibold tracking-tight text-slate-900 tabular-nums dark:text-white">
                    {minsAway}
                  </span>
                  <span className="text-sm font-medium text-slate-500 dark:text-slate-400">
                    min away
                  </span>
                </p>
              )}
              <p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
                {delivered
                  ? "Signed for by resident at Unit 14-02."
                  : `Arrives by ${arrival} · ${kmAway.toFixed(1)} km remaining`}
              </p>
              <p className="mt-3 text-xs font-medium text-indigo-600 dark:text-indigo-400">
                {current.title}
              </p>
            </div>

            <ol className="mt-4 flex-1">
              {STOPS.map((s, i) => {
                const done = s.t <= progress + 0.001;
                const isCurrent = s.id === current.id;
                return (
                  <motion.li
                    key={s.id}
                    initial={reduce ? false : { opacity: 0, x: -8 }}
                    whileInView={reduce ? undefined : { opacity: 1, x: 0 }}
                    viewport={{ once: true, margin: "-40px" }}
                    transition={{ duration: 0.35, delay: 0.12 + i * 0.06, ease: "easeOut" }}
                    className="relative"
                  >
                    {i < STOPS.length - 1 && (
                      <span
                        aria-hidden="true"
                        className={`absolute left-[17px] top-7 bottom-0 w-0.5 rounded-full ${
                          STOPS[i + 1].t <= progress + 0.001
                            ? "bg-indigo-500 dark:bg-indigo-400"
                            : "bg-slate-200 dark:bg-slate-800"
                        }`}
                      />
                    )}
                    <button
                      type="button"
                      aria-current={isCurrent ? "step" : undefined}
                      onClick={() => {
                        setPlaying(false);
                        setProgress(s.t);
                      }}
                      className={`relative flex w-full items-start gap-3 rounded-lg p-2 text-left transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/60 ${focusRing}`}
                    >
                      <span
                        className={`mt-0.5 grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full border-2 transition-colors ${
                          done
                            ? "border-indigo-500 bg-indigo-500 dark:border-indigo-400 dark:bg-indigo-400"
                            : "border-slate-300 bg-white dark:border-slate-700 dark:bg-slate-900"
                        }`}
                      >
                        {done && (
                          <svg viewBox="0 0 12 12" aria-hidden="true" className="h-2.5 w-2.5">
                            <path
                              d="M2.5 6.2 4.8 8.5 9.5 3.5"
                              fill="none"
                              stroke="currentColor"
                              strokeWidth="2"
                              strokeLinecap="round"
                              strokeLinejoin="round"
                              className="stroke-white dark:stroke-slate-900"
                            />
                          </svg>
                        )}
                      </span>
                      <span className="min-w-0 flex-1">
                        <span
                          className={`block text-sm font-medium ${
                            done ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"
                          }`}
                        >
                          {s.title}
                          {isCurrent && !delivered && (
                            <span className="ml-2 inline-flex items-center rounded-full bg-indigo-50 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700 dark:bg-indigo-950/70 dark:text-indigo-300">
                              Now
                            </span>
                          )}
                        </span>
                        <span className="mt-0.5 block text-xs leading-relaxed text-slate-500 dark:text-slate-400">
                          {s.detail}
                        </span>
                      </span>
                      <span
                        className={`shrink-0 pt-0.5 text-xs tabular-nums ${
                          done ? "text-slate-500 dark:text-slate-400" : "text-slate-400 dark:text-slate-600"
                        }`}
                      >
                        {fmtTime(START_MIN + s.t * TRIP_MIN)}
                      </span>
                    </button>
                  </motion.li>
                );
              })}
            </ol>

            <div className="mt-5 flex items-center justify-between gap-4 rounded-xl border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-900/70">
              <span className="min-w-0">
                <span id="mdt-notify-label" className="block text-sm font-medium text-slate-900 dark:text-white">
                  Ping me at 5 minutes
                </span>
                <span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">
                  Push alert when Priya turns onto your street
                </span>
              </span>
              <button
                type="button"
                role="switch"
                aria-checked={notify}
                aria-labelledby="mdt-notify-label"
                onClick={() => setNotify((v) => !v)}
                className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${focusRing} ${
                  notify ? "bg-indigo-600 dark:bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
                }`}
              >
                <span
                  aria-hidden="true"
                  className={`inline-block h-[1.125rem] w-[1.125rem] rounded-full bg-white shadow transition-transform ${
                    notify ? "translate-x-[1.375rem]" : "translate-x-1"
                  }`}
                />
              </button>
            </div>

            <p className="mt-4 text-xs text-slate-500 dark:text-slate-400">
              Delivery PIN{" "}
              <span className="font-semibold tabular-nums text-slate-900 dark:text-white">4821</span> ·
              Priya will call from the lobby if nobody answers.
            </p>
          </motion.div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →