Shipment Tracker
Data Display

Shipment Tracker

A multi-leg shipment route with a live position dot advancing along a curved path, plus ETA.

Install

npx shadcn@latest add @paragon/shipment-tracker

shipment-tracker.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface ShipmentLeg {
  /** Waypoint name, e.g. "Rotterdam" or "Chicago DC". */
  label: string;
  /** Optional caption, e.g. a date or code. */
  detail?: string;
}

export interface ShipmentTrackerProps
  extends React.ComponentProps<"div"> {
  legs: ShipmentLeg[];
  /**
   * Overall progress along the whole route, 0–1. The position dot advances to
   * this fraction of the path.
   */
  progress: number;
  /** ETA string shown in the header, e.g. "Jul 12, 4:30 PM". */
  eta?: string;
  /** Suppresses the path draw and dot advance. */
  static?: boolean;
}

const WIDTH = 560;
const HEIGHT = 120;
const PAD_X = 24;
/** First-view draw duration — chart-reveal budget, in-view once. */
const DRAW_MS = 600;

/**
 * A multi-leg shipment route: waypoints sit along a gently curved SVG path
 * and a live position dot advances to the overall progress fraction using
 * getPointAtLength, so it tracks the real curve. The path draws in once on
 * view and each waypoint tints the moment the dot passes it (transition
 * delays computed from its fraction of the route). Waypoint labels share the
 * same coordinate space as the SVG, so they stay under their markers at any
 * width and scroll with the path. Progress changes retarget the same
 * transitions, so live updates stay smooth. Reduced motion places everything
 * without movement.
 */
export function ShipmentTracker({
  legs,
  progress,
  eta,
  static: isStatic = false,
  className,
  ...props
}: ShipmentTrackerProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const pathRef = React.useRef<SVGPathElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
  const animate = !isStatic && !reducedMotion;
  const drawn = !animate || inView;

  const clamped = Math.min(Math.max(progress, 0), 1);
  const count = Math.max(legs.length, 2);

  // Deterministic gentle wave so the route reads as a path, not a bar.
  const points = React.useMemo(
    () =>
      legs.map((_, i) => {
        const x = PAD_X + (i / (count - 1)) * (WIDTH - PAD_X * 2);
        const y = HEIGHT / 2 + (i % 2 === 0 ? -14 : 14);
        return [x, y] as const;
      }),
    [legs, count],
  );

  // Smooth path through the waypoints (Catmull-Rom → cubic Bézier).
  const d = React.useMemo(() => {
    if (points.length < 2) return "";
    let path = `M ${points[0][0]} ${points[0][1]}`;
    for (let i = 0; i < points.length - 1; i++) {
      const p0 = points[Math.max(i - 1, 0)];
      const p1 = points[i];
      const p2 = points[i + 1];
      const p3 = points[Math.min(i + 2, points.length - 1)];
      const c1x = p1[0] + (p2[0] - p0[0]) / 6;
      const c1y = p1[1] + (p2[1] - p0[1]) / 6;
      const c2x = p2[0] - (p3[0] - p1[0]) / 6;
      const c2y = p2[1] - (p3[1] - p1[1]) / 6;
      path += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2[0]} ${p2[1]}`;
    }
    return path;
  }, [points]);

  // Resolve the dot position along the actual path length.
  const [dot, setDot] = React.useState<{ x: number; y: number }>(() =>
    points[0]
      ? { x: points[0][0], y: points[0][1] }
      : { x: PAD_X, y: HEIGHT / 2 },
  );

  React.useEffect(() => {
    const el = pathRef.current;
    if (!el) return;
    const len = el.getTotalLength();
    const target = drawn ? clamped : 0;
    const p = el.getPointAtLength(len * target);
    setDot({ x: p.x, y: p.y });
  }, [d, clamped, drawn]);

  // A waypoint is reached once the dot has passed its fraction of the route.
  const reachedIndex = Math.floor(clamped * (count - 1) + 1e-6);
  const percent = Math.round(clamped * 100);

  return (
    <div
      ref={ref}
      data-slot="shipment-tracker"
      className={cn(
        "w-full max-w-xl rounded-xl bg-card p-5 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="mb-1 flex items-baseline justify-between gap-3">
        <span className="min-w-0 truncate text-sm font-medium">
          In transit
          <span className="ml-2 font-normal text-muted-foreground tabular-nums">
            {percent}%
          </span>
        </span>
        {eta && (
          <span className="shrink-0 text-sm text-muted-foreground">
            ETA{" "}
            <span className="font-medium text-foreground tabular-nums">
              {eta}
            </span>
          </span>
        )}
      </div>

      <div className="overflow-x-auto">
        <div className="min-w-[480px]">
          <svg
            role="img"
            aria-label={`Shipment route from ${legs[0]?.label ?? "origin"} to ${
              legs[legs.length - 1]?.label ?? "destination"
            }, ${percent} percent complete`}
            viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
            className="block w-full"
          >
            {/* Base route */}
            <path
              d={d}
              fill="none"
              stroke="var(--color-border)"
              strokeWidth={2}
              strokeDasharray="1 6"
              strokeLinecap="round"
            />
            {/* Traveled portion */}
            <path
              ref={pathRef}
              d={d}
              fill="none"
              stroke="var(--color-primary)"
              strokeWidth={2.5}
              strokeLinecap="round"
              pathLength={1}
              style={{
                strokeDasharray: 1,
                strokeDashoffset: drawn ? 1 - clamped : 1,
                transition: animate
                  ? `stroke-dashoffset ${DRAW_MS}ms var(--ease-in-out)`
                  : undefined,
              }}
            />

            {/* Waypoints — each tints as the dot passes its route fraction. */}
            {points.map(([x, y], i) => {
              const reached = drawn && i <= reachedIndex;
              const fraction = i / (count - 1);
              const delay =
                animate && reached && clamped > 0
                  ? Math.min((fraction / clamped) * DRAW_MS, DRAW_MS)
                  : 0;
              return (
                <circle
                  key={i}
                  cx={x}
                  cy={y}
                  r={4}
                  fill="var(--color-background)"
                  stroke={
                    reached ? "var(--color-primary)" : "var(--color-border)"
                  }
                  strokeWidth={2}
                  style={{
                    transition: animate
                      ? `stroke 300ms var(--ease-out) ${delay}ms`
                      : undefined,
                  }}
                />
              );
            })}

            {/* Live position dot */}
            <g
              style={{
                transform: `translate(${dot.x}px, ${dot.y}px)`,
                transition: animate
                  ? `transform ${DRAW_MS}ms var(--ease-in-out)`
                  : undefined,
              }}
            >
              <circle
                r={7}
                fill="color-mix(in oklch, var(--color-primary) 22%, transparent)"
              />
              <circle
                r={4}
                fill="var(--color-primary)"
                stroke="var(--color-background)"
                strokeWidth={1.5}
              />
            </g>
          </svg>

          {/* Labels share the SVG's percentage space, so they track their
              markers at any width and scroll with the path. */}
          <ol className="relative mt-1 h-9">
            {legs.map((leg, i) => {
              const x = points[i]?.[0] ?? PAD_X;
              const first = i === 0;
              const last = i === legs.length - 1;
              return (
                <li
                  key={leg.label}
                  className={cn(
                    "absolute top-0",
                    first ? "text-left" : last ? "text-right" : "-translate-x-1/2 text-center",
                  )}
                  style={{
                    ...(first
                      ? { left: 0 }
                      : last
                        ? { right: 0 }
                        : { left: `${(x / WIDTH) * 100}%` }),
                    maxWidth: `${100 / count + 8}%`,
                  }}
                >
                  <p className="truncate text-xs font-medium" title={leg.label}>
                    {leg.label}
                  </p>
                  {leg.detail && (
                    <p
                      className="truncate text-[11px] text-muted-foreground tabular-nums"
                      title={leg.detail}
                    >
                      {leg.detail}
                    </p>
                  )}
                </li>
              );
            })}
          </ol>
        </div>
      </div>
    </div>
  );
}