Data Display

Capacity Fill Bar

A capacity fill bar that shifts color across warn/critical thresholds and flags overflow past capacity.

Install

npx shadcn@latest add @paragon/capacity-fill-bar

capacity-fill-bar.tsx

"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>
  );
}