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, a legend tallies each status, and both the
 * 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" });
  const animate = !isStatic && !reducedMotion;
  const shown = !animate || inView;

  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"
      className={cn("w-full max-w-xl", className)}
      {...props}
    >
      <style href="paragon-fleet-status-grid" precedence="paragon">{`
        @keyframes fleet-dot-pulse {
          0%, 100% { box-shadow: 0 0 0 0 var(--fleet-ring); }
          70% { box-shadow: 0 0 0 5px transparent; }
        }
        @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); }
        }
        [data-fleet-card][data-state="open"] {
          animation: fleet-card-in 150ms var(--ease-out);
        }
        @media (prefers-reduced-motion: reduce) {
          [data-fleet-pulse] { animation: none !important; }
          [data-fleet-tile] { animation: none !important; opacity: 1 !important; transform: none !important; filter: none !important; }
          [data-fleet-card][data-state="open"] { 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] 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]",
                      )}
                      style={
                        animate
                          ? {
                              opacity: shown ? undefined : 0,
                              animation: shown
                                ? `fleet-tile-in 320ms var(--ease-out) ${i * 40}ms both`
                                : undefined,
                            }
                          : undefined
                      }
                    >
                      <span
                        data-fleet-pulse={pulse ? "" : undefined}
                        aria-hidden
                        className={cn(
                          "size-2.5 shrink-0 rounded-full",
                          meta.dot,
                        )}
                        style={
                          pulse
                            ? {
                                animation:
                                  "fleet-dot-pulse 2s ease-out infinite",
                                ["--fleet-ring" as string]: meta.ring,
                              }
                            : undefined
                        }
                      />
                      <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">
          {STATUS_ORDER.filter((s) => counts[s] > 0).map((s) => {
            const meta = STATUS_META[s];
            return (
              <li
                key={s}
                className="flex items-center gap-1.5 text-xs text-muted-foreground"
              >
                <span 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>
  );
}