A live ETA card counting down to arrival, flashing a delay tint when the estimate slips and settling into an arrived state, with stops on a progress rail.
npx shadcn@latest add @paragon/route-eta-card"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { Check, Clock, MapPin } from "lucide-react";
import { cn } from "@/lib/utils";
export interface RouteStop {
label: string;
/** Optional caption, e.g. window or address. */
detail?: string;
done?: boolean;
}
export interface RouteEtaCardProps
extends React.ComponentProps<"div"> {
/** Destination name. */
destination: string;
/** Seconds remaining until arrival at mount. Counts down live. */
secondsRemaining: number;
stops: RouteStop[];
/** Suppresses the tick countdown and delay flash. */
static?: boolean;
}
function formatDuration(totalSeconds: number) {
const s = Math.max(Math.floor(totalSeconds), 0);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
return `${m}:${String(sec).padStart(2, "0")}`;
}
/**
* A live ETA card that counts down from a seeded seconds-remaining value
* (never Date.now in render — the mount value is captured once and ticked by
* an interval). When the remaining time jumps upward — a delay — the readout
* flashes with a warning tint; hitting zero settles into an arrived state.
* Stops sit on a connector rail with completed legs tinted, done stops
* checked off. Only status changes are announced to screen readers — never
* the per-second tick. The countdown and flash are suppressed under reduced
* motion or `static`.
*/
export function RouteEtaCard({
destination,
secondsRemaining,
stops,
static: isStatic = false,
className,
...props
}: RouteEtaCardProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const [remaining, setRemaining] = React.useState(secondsRemaining);
const [delayed, setDelayed] = React.useState(false);
const prevProp = React.useRef(secondsRemaining);
const flashTimeout = React.useRef<ReturnType<typeof setTimeout>>(null);
// Live tick.
React.useEffect(() => {
if (!animate) return;
const interval = setInterval(() => {
setRemaining((r) => Math.max(r - 1, 0));
}, 1000);
return () => clearInterval(interval);
}, [animate]);
// React to a new ETA from props: sync value and flash on a delay (increase).
React.useEffect(() => {
if (secondsRemaining > prevProp.current + 1 && animate) {
setDelayed(true);
if (flashTimeout.current) clearTimeout(flashTimeout.current);
flashTimeout.current = setTimeout(() => setDelayed(false), 1200);
}
prevProp.current = secondsRemaining;
setRemaining(secondsRemaining);
}, [secondsRemaining, animate]);
React.useEffect(() => {
return () => {
if (flashTimeout.current) clearTimeout(flashTimeout.current);
};
}, []);
const arrived = remaining <= 0;
const status = arrived ? "Arrived" : delayed ? "Delayed" : "On time";
return (
<div
data-slot="route-eta-card"
className={cn(
"w-full max-w-sm rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">
Arriving at
</p>
<p className="truncate text-sm font-medium" title={destination}>
{destination}
</p>
</div>
{/* Status changes (not the tick) are the only live announcements. */}
<span
aria-live="polite"
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full px-2 py-1 text-xs font-medium transition-colors duration-(--duration-base) ease-(--ease-out)",
arrived
? "bg-success/15 text-success"
: delayed
? "bg-warning/15 text-warning"
: "bg-secondary text-muted-foreground",
)}
>
{arrived ? (
<Check aria-hidden className="size-3.5" />
) : (
<Clock aria-hidden className="size-3.5" />
)}
{status}
</span>
</div>
<div
className={cn(
"mt-4 rounded-lg p-4 text-center transition-colors duration-(--duration-base) ease-(--ease-out)",
arrived
? "bg-success/10"
: delayed
? "bg-warning/10"
: "bg-secondary/50",
)}
>
<p
className={cn(
"text-3xl font-semibold tabular-nums transition-colors duration-(--duration-base) ease-(--ease-out)",
arrived
? "text-success"
: delayed
? "text-warning"
: "text-foreground",
)}
>
{formatDuration(remaining)}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{arrived ? "at destination" : "remaining"}
</p>
</div>
<ol className="mt-4 flex flex-col">
{stops.map((stop, i) => {
const nextDone = stops[i + 1]?.done ?? false;
return (
<li
key={`${stop.label}-${i}`}
className="relative flex items-start gap-3 pb-3 last:pb-0"
>
{/* Connector into the next stop; tinted once that leg is done. */}
{i < stops.length - 1 && (
<span
aria-hidden
className={cn(
"absolute top-[22px] bottom-[2px] left-[9.5px] w-px transition-colors duration-(--duration-base) ease-(--ease-out)",
nextDone ? "bg-primary" : "bg-border",
)}
/>
)}
<span
aria-hidden
className={cn(
"z-[1] mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full transition-colors duration-(--duration-base) ease-(--ease-out)",
stop.done
? "bg-primary text-primary-foreground"
: "border border-border bg-background text-muted-foreground",
)}
>
{stop.done ? (
<Check className="size-3" strokeWidth={2.5} />
) : (
<MapPin className="size-3" />
)}
</span>
<span className="min-w-0">
<span
className={cn(
"block truncate text-sm",
stop.done
? "text-muted-foreground line-through"
: "font-medium text-foreground",
)}
title={stop.label}
>
{stop.label}
</span>
{stop.detail && (
<span
className="block truncate text-xs text-muted-foreground tabular-nums"
title={stop.detail}
>
{stop.detail}
</span>
)}
</span>
</li>
);
})}
</ol>
</div>
);
}