Vehicle tiles with moving/idle/maintenance status dots and a hover-or-focus detail card.
npx shadcn@latest add @paragon/fleet-status-grid"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>
);
}