A container journey rail with milestone stops and a live vessel marker positioned by fractional progress.
npx shadcn@latest add @paragon/container-trackerAlso installs: world-map, tooltip
"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { WorldMap, project } from "@/registry/paragon/ui/world-map";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export interface ContainerMilestone {
label: string;
/** Short location or timestamp caption. */
detail?: string;
/** Optional geographic coordinates — drawn as a stop on the route map. */
lng?: number;
lat?: number;
}
export interface ContainerTrackerProps extends React.ComponentProps<"div"> {
/** Container / booking number. */
containerId: string;
milestones: readonly ContainerMilestone[];
/** Fractional progress 0–1 of the vessel along the rail. */
progress: number;
/** Index of the most recently completed milestone (inclusive). */
currentIndex: number;
/** Render the geographic route map when milestones carry coordinates. */
showMap?: boolean;
/** Suppresses the rail draw-in animation. */
static?: boolean;
}
const RAIL_X0 = 10;
const RAIL_W = 540;
/** Catmull-Rom → cubic Bézier smoothing through projected waypoints. */
function smoothPath(pts: { x: number; y: number }[]): string {
if (pts.length === 0) return "";
if (pts.length === 1) return `M${pts[0].x} ${pts[0].y}`;
let d = `M${pts[0].x} ${pts[0].y}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] ?? pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] ?? p2;
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C${c1x} ${c1y} ${c2x} ${c2y} ${p2.x} ${p2.y}`;
}
return d;
}
/**
* A container journey rendered as a horizontal rail with milestone stops and a
* live vessel marker positioned by fractional progress. When milestones carry
* lng/lat, a geographic route is drawn on the accurate world-map: the traveled
* portion animates via a stroke-dashoffset reveal and the vessel rides the
* computed path. The rail's traveled portion grows via scaleX and the vessel
* translates along a single computed coordinate space. Transform/clip only,
* reduced-motion-safe. Deterministic: position comes from props, never Date.now.
*/
export function ContainerTracker({
containerId,
milestones,
progress,
currentIndex,
showMap = true,
static: isStatic = false,
className,
...props
}: ContainerTrackerProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const [mounted, setMounted] = React.useState(false);
const pathRef = React.useRef<SVGPathElement>(null);
const [pathLen, setPathLen] = React.useState(0);
React.useEffect(() => {
const id = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(id);
}, []);
const pct = Math.min(Math.max(progress, 0), 1) * 100;
const frac = pct / 100;
const reveal = animate && !mounted ? 0 : pct;
const geoStops = milestones.filter(
(m) => typeof m.lng === "number" && typeof m.lat === "number",
);
const hasMap = showMap && geoStops.length >= 2;
const projected = React.useMemo(
() => geoStops.map((m) => project(m.lng as number, m.lat as number)),
[geoStops],
);
const routeD = React.useMemo(() => smoothPath(projected), [projected]);
// Vessel point on the projected route at the current fraction.
React.useLayoutEffect(() => {
if (pathRef.current) setPathLen(pathRef.current.getTotalLength());
}, [routeD]);
const vessel = React.useMemo(() => {
if (!pathRef.current || pathLen === 0) return null;
return pathRef.current.getPointAtLength(frac * pathLen);
}, [frac, pathLen]);
return (
<TooltipProvider>
<div
data-slot="container-tracker"
className={cn(
"w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between gap-3">
<p className="min-w-0 truncate font-mono text-sm font-medium tabular-nums">
{containerId}
</p>
<span className="shrink-0 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium tabular-nums text-muted-foreground">
{Math.round(pct)}% complete
</span>
</div>
{hasMap && (
<div className="relative mt-4 overflow-hidden rounded-lg bg-secondary/40 shadow-border">
<WorldMap tooltip={false} />
{/* Route overlay shares the world-map viewBox exactly, so every
waypoint lands on the correct landmass. */}
<svg
viewBox="0 0 1010 666"
aria-hidden
className="pointer-events-none absolute inset-0 h-full w-full"
preserveAspectRatio="none"
>
<path
ref={pathRef}
d={routeD}
fill="none"
stroke="var(--color-border)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
{pathLen > 0 && (
<path
d={routeD}
fill="none"
stroke="var(--color-primary)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="transition-[stroke-dashoffset] duration-1000 ease-out motion-reduce:transition-none"
style={{
strokeDasharray: pathLen,
strokeDashoffset: pathLen * (1 - (mounted || !animate ? frac : 0)),
}}
/>
)}
{projected.map((p, i) => {
const reached = i <= currentIndex;
return (
<circle
key={i}
cx={p.x}
cy={p.y}
r={3.5}
stroke="var(--color-card)"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
className={cn(
"transition-colors duration-300",
reached ? "fill-primary" : "fill-border",
)}
/>
);
})}
{vessel && (
<g
className="transition-transform duration-1000 ease-out motion-reduce:transition-none"
style={{
transform: `translate(${vessel.x}px, ${vessel.y}px)`,
}}
>
<circle
r={5}
className="fill-foreground"
stroke="var(--color-card)"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
</g>
)}
</svg>
</div>
)}
{/* Linear milestone rail — every coordinate computed from the shared scale. */}
<svg
aria-hidden
viewBox="0 0 560 32"
className="mt-6 w-full"
preserveAspectRatio="none"
>
<rect
x={RAIL_X0}
y={14}
width={RAIL_W}
height={4}
rx={2}
className="fill-secondary"
/>
<rect
x={RAIL_X0}
y={14}
width={RAIL_W}
height={4}
rx={2}
className="origin-left fill-primary transition-transform duration-700 ease-out motion-reduce:transition-none"
style={{
transformBox: "fill-box",
transform: `scaleX(${reveal / 100})`,
}}
/>
{milestones.map((_, i) => {
const x =
RAIL_X0 +
(milestones.length > 1 ? i / (milestones.length - 1) : 0) * RAIL_W;
const reached = i <= currentIndex;
return (
<circle
key={i}
cx={x}
cy={16}
r={4}
className={cn(
"stroke-card transition-colors duration-300 [stroke-width:3px]",
reached ? "fill-primary" : "fill-border",
)}
/>
);
})}
<g
className="transition-transform duration-700 ease-out motion-reduce:transition-none"
style={{ transform: `translateX(${(reveal / 100) * RAIL_W}px)` }}
>
<circle cx={RAIL_X0} cy={16} r={8} className="fill-foreground" />
<path
d="M6 17h8l-1 2.5H7L6 17Zm1-.5v-3l3 1 3-1v3H7Z"
className="fill-background"
transform="translate(2 -1)"
/>
</g>
</svg>
<ol className="mt-6 grid grid-cols-2 gap-x-3 gap-y-4 sm:grid-cols-4">
{milestones.map((m, i) => {
const reached = i <= currentIndex;
const isCurrent = i === currentIndex;
return (
<li key={`${m.label}-${i}`} className="min-w-0">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="group flex w-full min-w-0 flex-col items-start rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
<span
className={cn(
"flex w-full min-w-0 items-center gap-1 text-xs font-medium",
reached ? "text-foreground" : "text-muted-foreground",
)}
>
{isCurrent && (
<span
aria-hidden
className="size-1.5 shrink-0 rounded-full bg-primary"
/>
)}
<span className="truncate">{m.label}</span>
</span>
{m.detail && (
<span className="mt-0.5 w-full truncate text-[11px] tabular-nums text-muted-foreground">
{m.detail}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent>
{m.label}
{m.detail ? ` — ${m.detail}` : ""}
</TooltipContent>
</Tooltip>
</li>
);
})}
</ol>
</div>
</TooltipProvider>
);
}