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;
}

/**
 * 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 and the
 * dot advances once on view. Reduced motion places the dot without motion.
 */
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 width = 560;
  const height = 120;
  const padX = 24;
  const midY = height / 2;
  const count = Math.max(legs.length, 2);

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

  // 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;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [JSON.stringify(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: padX, y: midY });

  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]);

  const reachedIndex = Math.round(clamped * (count - 1));

  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">
        <span className="text-sm font-medium">In transit</span>
        {eta && (
          <span className="text-sm text-muted-foreground">
            ETA <span className="font-medium text-foreground tabular-nums">{eta}</span>
          </span>
        )}
      </div>

      <div className="overflow-x-auto">
        <svg
          role="img"
          aria-label={`Shipment route from ${legs[0]?.label ?? "origin"} to ${
            legs[legs.length - 1]?.label ?? "destination"
          }, ${Math.round(clamped * 100)} percent complete`}
          viewBox={`0 0 ${width} ${height}`}
          width={width}
          height={height}
          className="block w-full min-w-[480px]"
        >
          {/* 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 900ms var(--ease-in-out)"
                : undefined,
            }}
          />

          {/* Waypoints */}
          {points.map(([x, y], i) => {
            const reached = i <= reachedIndex;
            return (
              <g key={i}>
                <circle
                  cx={x}
                  cy={y}
                  r={4}
                  fill="var(--color-background)"
                  stroke={reached ? "var(--color-primary)" : "var(--color-border)"}
                  strokeWidth={2}
                  style={{ transition: "stroke 300ms var(--ease-out)" }}
                />
              </g>
            );
          })}

          {/* Live position dot */}
          <g
            style={{
              transform: `translate(${dot.x}px, ${dot.y}px)`,
              transition: animate
                ? "transform 900ms 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>
      </div>

      <ol className="mt-1 flex justify-between gap-2 text-center">
        {legs.map((leg, i) => (
          <li key={leg.label} className="min-w-0 flex-1">
            <p className="truncate text-xs font-medium">{leg.label}</p>
            {leg.detail && (
              <p className="truncate text-[11px] text-muted-foreground tabular-nums">
                {leg.detail}
              </p>
            )}
          </li>
        ))}
      </ol>
    </div>
  );
}