Fleet Status Grid
Data Display

Fleet Status Grid

Vehicle tiles with moving/idle/maintenance status dots and a hover-or-focus detail card.

Install

npx shadcn@latest add @paragon/fleet-status-grid

fleet-status-grid.tsx

"use client";

import * as React from "react";
import * as HoverCard from "@radix-ui/react-hover-card";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type FleetStatus = "moving" | "idle" | "maintenance" | "offline";

export interface FleetVehicle {
  id: string;
  /** Display name, e.g. "TRK-118". */
  name: string;
  status: FleetStatus;
  /** Current location or route label. */
  location?: string;
  /** Driver name. */
  driver?: string;
  /** Speed in mph, shown for moving vehicles. */
  speed?: number;
  /** Optional secondary metric, e.g. "ETA 14m" or "Battery 82%". */
  metric?: string;
}

const STATUS_META: Record<
  FleetStatus,
  { label: string; dot: string; text: string; ring: string }
> = {
  moving: {
    label: "Moving",
    dot: "bg-success",
    text: "text-success",
    ring: "color-mix(in oklch, var(--color-success) 45%, transparent)",
  },
  idle: {
    label: "Idle",
    dot: "bg-warning",
    text: "text-warning",
    ring: "color-mix(in oklch, var(--color-warning) 45%, transparent)",
  },
  maintenance: {
    label: "Maintenance",
    dot: "bg-destructive",
    text: "text-destructive",
    ring: "color-mix(in oklch, var(--color-destructive) 45%, transparent)",
  },
  offline: {
    label: "Offline",
    dot: "bg-muted-foreground",
    text: "text-muted-foreground",
    ring: "transparent",
  },
};

const STATUS_ORDER: FleetStatus[] = [
  "moving",
  "idle",
  "maintenance",
  "offline",
];

export interface FleetStatusGridProps
  extends React.ComponentProps<"div"> {
  vehicles: FleetVehicle[];
  /** Columns at the widest breakpoint. */
  columns?: number;
  /** Show the status legend with per-status counts. */
  legend?: boolean;
  /** Suppresses the moving-status pulse and enter stagger. */
  static?: boolean;
}

/**
 * A grid of vehicle tiles with a semantic status dot (moving / idle /
 * maintenance / offline). Moving dots pulse gently; hovering or keyboard-
 * focusing a tile opens a Radix hover card with driver, location, and speed.
 * Tiles stagger in on first view, and the legend cross-highlights: hovering
 * a status quiets every tile that doesn't match (opacity-only, so it also
 * works under reduced motion). Pulse and stagger freeze under reduced motion.
 */
export function FleetStatusGrid({
  vehicles,
  columns = 3,
  legend = true,
  static: isStatic = false,
  className,
  ...props
}: FleetStatusGridProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
  // Separate non-once observer: the moving-status ping pauses offscreen.
  const pingInView = useInView(ref, { amount: 0.05 });
  const animate = !isStatic && !reducedMotion;
  const shown = !animate || inView;
  // Legend → grid cross-highlight: the hovered status keeps its tiles at
  // full strength while the rest recede.
  const [focusStatus, setFocusStatus] = React.useState<FleetStatus | null>(
    null,
  );

  const counts = React.useMemo(() => {
    const c: Record<FleetStatus, number> = {
      moving: 0,
      idle: 0,
      maintenance: 0,
      offline: 0,
    };
    for (const v of vehicles) c[v.status] += 1;
    return c;
  }, [vehicles]);

  return (
    <div
      ref={ref}
      data-slot="fleet-status-grid"
      data-fleet-paused={pingInView ? undefined : ""}
      className={cn("w-full max-w-xl", className)}
      {...props}
    >
      <style href="paragon-fleet-status-grid" precedence="paragon">{`
        @keyframes fleet-dot-ping {
          0% { scale: 1; opacity: 0.55; }
          70%, 100% { scale: 2.75; opacity: 0; }
        }
        [data-fleet-pulse] {
          transform-origin: center;
          animation: fleet-dot-ping 2s var(--ease-out) infinite;
        }
        [data-fleet-paused] [data-fleet-pulse] { animation-play-state: paused; }
        @keyframes fleet-tile-in {
          from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
          to { opacity: 1; transform: none; filter: blur(0); }
        }
        @keyframes fleet-card-in {
          from { opacity: 0; transform: translateY(4px) scale(0.97); filter: blur(4px); }
          to { opacity: 1; transform: none; filter: blur(0); }
        }
        @keyframes fleet-card-out {
          to { opacity: 0; }
        }
        [data-fleet-card][data-state="open"] {
          animation: fleet-card-in 150ms var(--ease-out);
        }
        [data-fleet-card][data-state="closed"] {
          animation: fleet-card-out 75ms var(--ease-exit) forwards;
        }
        @media (prefers-reduced-motion: reduce) {
          [data-fleet-pulse] { animation: none !important; opacity: 0; }
          [data-fleet-tile] { animation: none !important; transform: none !important; filter: none !important; }
          [data-fleet-card][data-state="open"],
          [data-fleet-card][data-state="closed"] { animation: none !important; }
        }
      `}</style>

      {vehicles.length === 0 ? (
        <div className="flex h-28 items-center justify-center rounded-lg bg-secondary/40 text-sm text-muted-foreground shadow-border">
          No vehicles in this fleet.
        </div>
      ) : (
        <div
          className="grid gap-2"
          style={{
            gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
          }}
        >
          {vehicles.map((vehicle, i) => {
              const meta = STATUS_META[vehicle.status];
              const pulse = vehicle.status === "moving" && !isStatic;
              return (
                <HoverCard.Root
                  key={vehicle.id}
                  openDelay={120}
                  closeDelay={80}
                >
                  <HoverCard.Trigger asChild>
                    <button
                      type="button"
                      data-fleet-tile
                      className={cn(
                        "group flex items-center gap-2.5 rounded-lg bg-card p-3 text-left shadow-border outline-none",
                        "transition-[box-shadow,scale,opacity] duration-150 ease-out",
                        "hover:shadow-border-hover focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                        !isStatic && "active:scale-[0.97]",
                        focusStatus !== null &&
                          focusStatus !== vehicle.status &&
                          "opacity-40",
                      )}
                      style={
                        animate
                          ? {
                              opacity: shown ? undefined : 0,
                              // `backwards` (not `both`): once the enter lands,
                              // the keyframe lets go so the legend's
                              // cross-highlight opacity can take over.
                              animation: shown
                                ? `fleet-tile-in 320ms var(--ease-out) ${i * 40}ms backwards`
                                : undefined,
                            }
                          : undefined
                      }
                    >
                      <span
                        aria-hidden
                        className="relative inline-flex size-2.5 shrink-0"
                      >
                        {/* Expanding halo — transform/opacity only, pauses offscreen. */}
                        {pulse && (
                          <span
                            data-fleet-pulse=""
                            className="pointer-events-none absolute inset-0 rounded-full"
                            style={{ background: meta.ring }}
                          />
                        )}
                        <span
                          className={cn(
                            "relative size-full rounded-full",
                            meta.dot,
                          )}
                        />
                      </span>
                      <span className="min-w-0 flex-1">
                        <span className="block truncate text-sm font-medium tabular-nums">
                          {vehicle.name}
                        </span>
                        <span className={cn("block truncate text-xs", meta.text)}>
                          {meta.label}
                        </span>
                      </span>
                    </button>
                  </HoverCard.Trigger>
                  <HoverCard.Portal>
                    <HoverCard.Content
                      data-fleet-card
                      side="top"
                      align="center"
                      sideOffset={6}
                      className="z-50 w-56 origin-[var(--radix-hover-card-content-transform-origin)] rounded-lg bg-popover p-3 text-popover-foreground shadow-overlay"
                    >
                      <div className="flex items-center justify-between gap-2">
                        <span className="truncate text-sm font-medium tabular-nums">
                          {vehicle.name}
                        </span>
                        <span
                          className={cn(
                            "flex shrink-0 items-center gap-1.5 text-xs",
                            meta.text,
                          )}
                        >
                          <span className={cn("size-2 rounded-full", meta.dot)} />
                          {meta.label}
                        </span>
                      </div>
                      <dl className="mt-2 space-y-1 text-xs">
                        {vehicle.driver && (
                          <div className="flex justify-between gap-2">
                            <dt className="text-muted-foreground">Driver</dt>
                            <dd className="truncate font-medium">
                              {vehicle.driver}
                            </dd>
                          </div>
                        )}
                        {vehicle.location && (
                          <div className="flex justify-between gap-2">
                            <dt className="shrink-0 text-muted-foreground">
                              Location
                            </dt>
                            <dd className="truncate font-medium">
                              {vehicle.location}
                            </dd>
                          </div>
                        )}
                        {vehicle.status === "moving" &&
                          vehicle.speed != null && (
                            <div className="flex justify-between gap-2">
                              <dt className="text-muted-foreground">Speed</dt>
                              <dd className="font-medium tabular-nums">
                                {vehicle.speed} mph
                              </dd>
                            </div>
                          )}
                        {vehicle.metric && (
                          <div className="flex justify-between gap-2">
                            <dt className="text-muted-foreground">Status</dt>
                            <dd className="truncate font-medium tabular-nums">
                              {vehicle.metric}
                            </dd>
                          </div>
                        )}
                      </dl>
                      <HoverCard.Arrow className="fill-popover" />
                    </HoverCard.Content>
                  </HoverCard.Portal>
                </HoverCard.Root>
              );
            })}
        </div>
      )}

      {legend && vehicles.length > 0 && (
        <ul
          className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5"
          onPointerLeave={() => setFocusStatus(null)}
        >
          {STATUS_ORDER.filter((s) => counts[s] > 0).map((s) => {
            const meta = STATUS_META[s];
            return (
              <li
                key={s}
                onPointerEnter={(event) => {
                  if (event.pointerType === "mouse") setFocusStatus(s);
                }}
                className={cn(
                  "flex items-center gap-1.5 text-xs text-muted-foreground",
                  "transition-opacity duration-(--duration-fast)",
                  focusStatus !== null && focusStatus !== s && "opacity-50",
                )}
              >
                <span aria-hidden className={cn("size-2 rounded-full", meta.dot)} />
                <span>{meta.label}</span>
                <span className="font-medium text-foreground tabular-nums">
                  {counts[s]}
                </span>
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}