A capacity fill bar that shifts color across warn/critical thresholds and flags overflow past capacity.
npx shadcn@latest add @paragon/capacity-fill-bar"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface CapacitySegment {
/** Segment label, e.g. "Refrigerated". */
label: string;
/** Amount this segment consumes, in the same unit as `capacity`. */
value: number;
/**
* Segment color. Any CSS color / token. Defaults walk a semantic ramp when
* omitted. Use `var(--color-*)` tokens to stay themable.
*/
color?: string;
}
export interface CapacityFillBarProps
extends Omit<React.ComponentProps<"div">, "color"> {
/** Total capacity the segments fill toward. */
capacity: number;
/** Stacked segments. Their sum is the used amount. */
segments: CapacitySegment[];
/** Fraction (0–1) at which a threshold marker is drawn. Hidden if unset. */
threshold?: number;
/** Label for the threshold marker, shown in the tooltip. */
thresholdLabel?: string;
/** Unit label, e.g. "pallets" or "cu ft". */
unit?: string;
label?: string;
/** Formats numeric values. */
format?: (value: number) => string;
/** Show the per-segment legend below the bar. */
legend?: boolean;
/** Grow-in speed multiplier (1 = default). Higher is faster. */
speed?: number;
/** Suppresses the fill grow-in. */
static?: boolean;
}
/** Default segment palette — semantic tokens, theme-aware. */
const DEFAULT_COLORS = [
"var(--color-primary)",
"color-mix(in oklch, var(--color-primary) 55%, var(--color-muted))",
"var(--color-success)",
"var(--color-warning)",
"color-mix(in oklch, var(--color-primary) 30%, var(--color-muted))",
];
/**
* A segmented capacity bar for a trailer, warehouse bay, or server pool.
* Segment widths are computed exactly as a fraction of `capacity` (last drawn
* segment absorbs any rounding remainder so the fill never leaves a sub-pixel
* gap). Each segment carries a hover/focus tooltip with its label and value; an
* optional threshold marker and legend round it out. The stack grows in once on
* view via an interruptible transform; reduced motion shows it filled.
*/
export function CapacityFillBar({
capacity,
segments,
threshold,
thresholdLabel = "Threshold",
unit,
label,
format = (v) => v.toLocaleString("en-US"),
legend = true,
speed = 1,
static: isStatic = false,
className,
...props
}: CapacityFillBarProps) {
const safeSpeed = Math.max(speed, 0.1);
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 drawn = !animate || inView;
const uid = React.useId();
const safeCapacity = Math.max(capacity, 1e-9);
const used = segments.reduce((sum, s) => sum + Math.max(s.value, 0), 0);
const usedFraction = used / safeCapacity;
const overflow = usedFraction > 1;
const usedPct = Math.round(usedFraction * 100);
// Exact segment geometry. Widths are percentages of capacity, clamped so an
// overflowing stack never exceeds the track; the last *visible* segment
// absorbs the rounding remainder so the fill closes flush.
const laidOut = React.useMemo(() => {
let acc = 0; // cumulative fraction consumed of the track (0–1)
const rows = segments.map((seg, i) => {
const value = Math.max(seg.value, 0);
const rawFraction = value / safeCapacity;
const start = acc;
const end = Math.min(acc + rawFraction, 1);
acc = Math.min(acc + rawFraction, 1);
return {
...seg,
index: i,
value,
widthPct: (end - start) * 100,
color: seg.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length],
visible: end - start > 0,
};
});
// Snap the last visible segment's right edge to the exact cumulative sum so
// there is no sub-pixel gap before the (clamped) fill end.
const visible = rows.filter((r) => r.visible);
if (visible.length > 0) {
const totalPct = visible.reduce((s, r) => s + r.widthPct, 0);
const targetPct = Math.min(usedFraction, 1) * 100;
const last = visible[visible.length - 1];
last.widthPct += targetPct - totalPct;
}
return rows;
}, [segments, safeCapacity, usedFraction]);
const hasData = segments.length > 0 && used > 0;
return (
<div
ref={ref}
data-slot="capacity-fill-bar"
className={cn("w-full max-w-md", className)}
{...props}
>
<div className="mb-1.5 flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-sm font-medium">
{label ?? "Capacity"}
</span>
<span className="shrink-0 text-sm text-muted-foreground tabular-nums">
{format(Math.round(used))} / {format(capacity)}
{unit ? ` ${unit}` : ""}
</span>
</div>
<div
role="progressbar"
aria-valuenow={Math.round(used)}
aria-valuemin={0}
aria-valuemax={capacity}
aria-label={`${label ?? "Capacity"}: ${usedPct}% used${
overflow ? ", over capacity" : ""
}`}
className={cn(
"group/track relative h-3.5 w-full overflow-hidden rounded-full",
"bg-secondary shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--color-border)_60%,transparent)]",
)}
>
{/* Stacked segments */}
<div className="flex h-full w-full">
{laidOut.map((seg) =>
seg.visible ? (
<SegmentCell
key={`${seg.label}-${seg.index}`}
seg={seg}
unit={unit}
format={format}
capacity={capacity}
animate={animate}
drawn={drawn}
speed={safeSpeed}
uid={uid}
/>
) : null,
)}
</div>
{/* Threshold marker */}
{threshold != null && threshold > 0 && threshold <= 1 && (
<span
aria-hidden
className="absolute inset-y-0 z-10 w-px bg-foreground/40"
style={{ left: `${threshold * 100}%` }}
title={`${thresholdLabel} · ${Math.round(threshold * 100)}%`}
>
<span className="absolute -top-0.5 left-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-foreground/70" />
</span>
)}
{/* Overflow cap */}
{overflow && (
<span
aria-hidden
className="absolute inset-y-0 right-0 w-1 bg-destructive"
style={{
boxShadow:
"0 0 6px 1px color-mix(in oklch, var(--color-destructive) 60%, transparent)",
}}
/>
)}
</div>
{/* Summary row */}
<div className="mt-1.5 flex items-center justify-between gap-3 text-xs">
<span
className={cn(
"font-medium tabular-nums",
overflow ? "text-destructive" : "text-muted-foreground",
)}
>
{usedPct}% used
</span>
{overflow ? (
<span className="font-medium text-destructive tabular-nums">
Over by {format(Math.round(used - capacity))}
{unit ? ` ${unit}` : ""}
</span>
) : (
<span className="text-muted-foreground tabular-nums">
{format(Math.round(capacity - used))}
{unit ? ` ${unit}` : ""} free
</span>
)}
</div>
{/* Legend */}
{legend && hasData && (
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
{laidOut.map((seg) =>
seg.value > 0 ? (
<li
key={`legend-${seg.label}-${seg.index}`}
className="flex min-w-0 items-center gap-1.5 text-xs"
>
<span
aria-hidden
className="size-2.5 shrink-0 rounded-[3px]"
style={{ background: seg.color }}
/>
<span className="min-w-0 truncate text-muted-foreground">
{seg.label}
</span>
<span className="shrink-0 font-medium tabular-nums">
{format(Math.round(seg.value))}
{unit ? ` ${unit}` : ""}
</span>
</li>
) : null,
)}
</ul>
)}
{legend && !hasData && (
<p className="mt-3 text-xs text-muted-foreground">Empty — no load assigned.</p>
)}
</div>
);
}
interface SegmentCellProps {
seg: {
label: string;
value: number;
color: string;
widthPct: number;
index: number;
};
unit?: string;
capacity: number;
format: (v: number) => string;
animate: boolean;
drawn: boolean;
speed: number;
uid: string;
}
function SegmentCell({
seg,
unit,
capacity,
format,
animate,
drawn,
speed,
uid,
}: SegmentCellProps) {
const [open, setOpen] = React.useState(false);
const pctOfCap = Math.round((seg.value / Math.max(capacity, 1e-9)) * 100);
const tooltipId = `${uid}-seg-${seg.index}`;
return (
<div
className="group/seg relative h-full min-w-0 shrink-0 first:rounded-l-full last:rounded-r-full"
style={{
width: `${seg.widthPct}%`,
// Grow-in: collapse to 0 before draw, then interpolate flex-basis via
// transform to avoid layout thrash. We scale the width container.
transform: drawn ? "scaleX(1)" : "scaleX(0)",
transformOrigin: "left",
transition: animate
? `transform ${Math.round(640 / speed)}ms var(--ease-out) ${Math.round(
(seg.index * 70) / speed,
)}ms`
: undefined,
}}
>
<button
type="button"
aria-describedby={open ? tooltipId : undefined}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
className={cn(
"block h-full w-full outline-none",
"transition-[filter,opacity] duration-150 ease-out",
"hover:brightness-110 focus-visible:brightness-110",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
)}
style={{ background: seg.color }}
>
<span className="sr-only">
{seg.label}: {format(seg.value)}
{unit ? ` ${unit}` : ""}
</span>
{/* Hairline divider between segments */}
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 w-px bg-background/25 group-last/seg:hidden"
/>
</button>
{/* Tooltip */}
<div
id={tooltipId}
role="tooltip"
className={cn(
"pointer-events-none absolute bottom-[calc(100%+8px)] left-1/2 z-20 -translate-x-1/2",
"whitespace-nowrap rounded-md bg-primary px-2.5 py-1.5 text-xs text-primary-foreground shadow-overlay",
"origin-bottom transition-[opacity,transform] duration-150 ease-out",
open ? "opacity-100" : "translate-y-1 scale-95 opacity-0",
)}
>
<span className="font-medium">{seg.label}</span>
<span className="ml-2 tabular-nums text-primary-foreground/80">
{format(seg.value)}
{unit ? ` ${unit}` : ""} · {pctOfCap}%
</span>
<span
aria-hidden
className="absolute top-full left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-primary"
/>
</div>
</div>
);
}