A zone and bin occupancy grid rendering warehouse utilization as a success-to-full heat map.
npx shadcn@latest add @paragon/warehouse-mapAlso installs: number-ticker, tooltip
"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export interface WarehouseBin {
/** Bin / slot label, e.g. "A-04". */
label: string;
/** Occupancy 0–1 driving the heat tint. */
fill: number;
}
export interface WarehouseZone {
name: string;
bins: WarehouseBin[];
}
/** Occupancy bands the heat scale is quantized into. */
export type HeatBand = "low" | "mid" | "full";
export interface WarehouseMapProps extends React.ComponentProps<"div"> {
zones: WarehouseZone[];
/** Columns per zone grid. */
columns?: number;
/** Suppresses the reveal + stagger animations. */
static?: boolean;
}
const BAND_META: Record<
HeatBand,
{ label: string; token: string; swatch: string }
> = {
low: { label: "Low", token: "var(--color-success)", swatch: "bg-success/70" },
mid: {
label: "Mid",
token: "var(--color-warning)",
swatch: "bg-warning/70",
},
full: {
label: "Full",
token: "var(--color-destructive)",
swatch: "bg-destructive/70",
},
};
const BAND_ORDER: HeatBand[] = ["low", "mid", "full"];
/** Quantize a 0–1 fill into a heat band. Thresholds are the tint breakpoints. */
function bandOf(fill: number): HeatBand {
const f = Math.min(Math.max(fill, 0), 1);
return f >= 0.85 ? "full" : f >= 0.6 ? "mid" : "low";
}
/** Tint a bin from its fill fraction: token mixed over the secondary surface. */
function heatStyle(fill: number): React.CSSProperties {
const f = Math.min(Math.max(fill, 0), 1);
const token = BAND_META[bandOf(f)].token;
return {
backgroundColor: `color-mix(in oklch, ${token} ${Math.round(
20 + f * 65,
)}%, var(--color-secondary))`,
};
}
/**
* A zone/bin occupancy grid rendering warehouse utilization as a heat map. Each
* bin's tint is derived deterministically from its fill fraction via a
* color-mix across success → warning → destructive tokens. Bins reveal in on
* first scroll into view with a short row-wise stagger; the header and each
* zone total count up via a number-ticker. Hovering (or focusing) a legend
* band highlights matching bins and dims the rest; every bin carries a tooltip
* with its exact occupancy. Deterministic — all fills arrive as props.
*/
export function WarehouseMap({
zones,
columns = 6,
static: isStatic = false,
className,
...props
}: WarehouseMapProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const rootRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(rootRef, {
once: true,
margin: "0px 0px -32px 0px",
});
const revealed = !animate || inView;
const [band, setBand] = React.useState<HeatBand | null>(null);
const allBins = React.useMemo(
() => zones.flatMap((z) => z.bins),
[zones],
);
const empty = zones.length === 0 || allBins.length === 0;
// Aggregate occupancy across every bin, plus a per-band count for the legend.
const { occupancy, counts } = React.useMemo(() => {
const c: Record<HeatBand, number> = { low: 0, mid: 0, full: 0 };
let sum = 0;
for (const b of allBins) {
sum += Math.min(Math.max(b.fill, 0), 1);
c[bandOf(b.fill)]++;
}
return {
occupancy: allBins.length ? (sum / allBins.length) * 100 : 0,
counts: c,
};
}, [allBins]);
// Running index across all bins so the reveal stagger is continuous
// across zone boundaries rather than resetting per zone.
let binIndex = 0;
return (
<TooltipProvider>
<div
ref={rootRef}
data-slot="warehouse-map"
className={cn(
"w-full max-w-lg rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">Warehouse occupancy</p>
<p className="mt-0.5 text-xs text-muted-foreground tabular-nums">
<NumberTicker
value={allBins.length}
static={!animate}
className="text-muted-foreground"
/>{" "}
bins across {zones.length}{" "}
{zones.length === 1 ? "zone" : "zones"}
</p>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="shrink-0 text-right outline-none">
<p className="text-xl font-semibold tabular-nums leading-none">
<NumberTicker
value={Math.round(occupancy)}
static={!animate}
/>
<span className="text-sm font-medium text-muted-foreground">
%
</span>
</p>
<p className="mt-1 text-[11px] text-muted-foreground">
utilization
</p>
</div>
</TooltipTrigger>
<TooltipContent>Mean fill across all bins</TooltipContent>
</Tooltip>
</div>
{empty ? (
<div className="mt-4 flex h-28 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-xs text-muted-foreground">No zones to display</p>
</div>
) : (
<div className="mt-4 flex flex-col gap-4">
{zones.map((zone) => {
const zoneSum = zone.bins.reduce(
(s, b) => s + Math.min(Math.max(b.fill, 0), 1),
0,
);
const zoneAvg = zone.bins.length
? (zoneSum / zone.bins.length) * 100
: 0;
return (
<div key={zone.name}>
<div className="mb-1.5 flex items-center justify-between gap-3">
<p className="min-w-0 truncate text-xs font-medium text-muted-foreground">
Zone {zone.name}
</p>
<p className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
<NumberTicker
value={Math.round(zoneAvg)}
static={!animate}
className="text-muted-foreground"
/>
% full
</p>
</div>
{/* Quiet fill bar — scaleX only, never width. */}
<div className="mb-2 h-1 overflow-hidden rounded-full bg-secondary">
<div
aria-hidden
className="h-full origin-left rounded-full bg-foreground/25 transition-transform duration-500 ease-out"
style={{
transform: `scaleX(${revealed ? zoneAvg / 100 : 0})`,
}}
/>
</div>
<div
className="grid gap-1"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
role="list"
aria-label={`Zone ${zone.name} bins`}
>
{zone.bins.map((bin) => {
const b = bandOf(bin.fill);
const dim = band !== null && band !== b;
const pct = Math.round(
Math.min(Math.max(bin.fill, 0), 1) * 100,
);
const i = binIndex++;
return (
<Tooltip key={bin.label}>
<TooltipTrigger asChild>
<motion.div
role="listitem"
tabIndex={0}
aria-label={`Bin ${bin.label}, ${pct}% full, ${BAND_META[b].label}`}
initial={
animate
? { opacity: 0, y: 12, filter: "blur(4px)" }
: false
}
animate={
revealed
? {
opacity: dim ? 0.3 : 1,
y: 0,
filter: "blur(0px)",
}
: undefined
}
transition={{
duration: 0.32,
ease: [0.22, 1, 0.36, 1],
delay: animate ? Math.min(i * 0.012, 0.4) : 0,
opacity: { duration: 0.15 },
}}
className={cn(
"relative flex aspect-square items-center justify-center rounded-[5px] outline-none ring-ring",
"transition-[box-shadow,scale] duration-150 ease-out",
"hover:shadow-border-hover focus-visible:ring-2",
!isStatic && "active:scale-[0.94]",
)}
style={heatStyle(bin.fill)}
>
<span className="text-[9px] font-medium tabular-nums text-foreground/70">
{bin.label}
</span>
</motion.div>
</TooltipTrigger>
<TooltipContent>
{bin.label} · {pct}% full
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
})}
</div>
)}
{!empty && (
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-1.5">
{BAND_ORDER.map((s) => (
<button
key={s}
type="button"
onPointerEnter={() => setBand(s)}
onPointerLeave={() => setBand(null)}
onFocus={() => setBand(s)}
onBlur={() => setBand(null)}
className={cn(
"flex items-center gap-1.5 rounded-md px-1 py-0.5 text-[11px] text-muted-foreground outline-none",
"transition-[background-color,scale] duration-150 ease-out",
"hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
!isStatic && "active:scale-[0.97]",
)}
>
<span
aria-hidden
className={cn("size-2.5 rounded-[3px]", BAND_META[s].swatch)}
/>
{BAND_META[s].label}
<span className="font-medium text-foreground tabular-nums">
{counts[s]}
</span>
</button>
))}
</div>
)}
</div>
</TooltipProvider>
);
}