A multi-leg shipment route with a live position dot advancing along a curved path, plus ETA.
npx shadcn@latest add @paragon/shipment-tracker"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>
);
}