Route ETA Card
Data Display

Route ETA Card

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.

Install

npx shadcn@latest add @paragon/route-eta-card

route-eta-card.tsx

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