Infrastructure & DevOps

Cost Explorer

A cloud-spend view with a stacked area chart by service, a month-to-date total that counts up on reveal, and top movers.

Install

npx shadcn@latest add @paragon/cost-explorer

Also installs: number-ticker, highlight-on-update

cost-explorer.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import { HighlightOnUpdate } from "@/registry/paragon/ui/highlight-on-update";

export interface CostCategory {
  name: string;
  color: string;
  /** Spend per period, oldest → newest. */
  series: number[];
}

export interface CostExplorerProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  categories?: CostCategory[];
  periodLabels?: string[];
  /** ISO/locale currency code for the total + tooltips. */
  currency?: string;
  /** Granularity label shown in the header (Daily / Weekly / Monthly). */
  granularity?: string;
  /** Opt out of the enter reveal. */
  static?: boolean;
}

const DEFAULT_CATEGORIES: CostCategory[] = [
  { name: "Compute", color: "var(--color-primary)", series: [820, 910, 980, 1040, 1120, 1210] },
  { name: "Storage", color: "var(--color-success)", series: [420, 430, 440, 448, 456, 470] },
  { name: "Data transfer", color: "var(--color-warning)", series: [180, 210, 240, 300, 360, 442] },
  { name: "Managed DB", color: "var(--color-muted-foreground)", series: [360, 355, 350, 344, 338, 330] },
];

const DEFAULT_LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];

const VB_W = 320;
const VB_H = 128;
const PAD = { t: 8, r: 8, b: 8, l: 8 };
const PLOT_W = VB_W - PAD.l - PAD.r;
const PLOT_H = VB_H - PAD.t - PAD.b;

export function CostExplorer({
  categories = DEFAULT_CATEGORIES,
  periodLabels = DEFAULT_LABELS,
  currency = "USD",
  granularity = "Monthly",
  static: isStatic = false,
  className,
  style,
  ...props
}: CostExplorerProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion() ?? false;
  const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
  const reveal = isStatic || reduced || inView;

  const [active, setActive] = React.useState<{ p: number; c: number } | null>(null);
  const [legend, setLegend] = React.useState<string | null>(null);

  const periods = periodLabels.length;

  // per-period totals for a shared domain → range y-scale
  const periodTotals = React.useMemo(
    () =>
      Array.from({ length: periods }, (_, i) =>
        categories.reduce((s, c) => s + (c.series[i] ?? 0), 0),
      ),
    [categories, periods],
  );
  const maxTotal = Math.max(1, ...periodTotals);

  // domain → range: value 0..maxTotal → pixel height within the plot
  const hFor = (v: number) => (v / maxTotal) * PLOT_H;
  const baselineY = PAD.t + PLOT_H;

  // stacked segments: cumulative bottom-offset per (period, category)
  const stacks = React.useMemo(() => {
    const offsets = new Array(periods).fill(0);
    return categories.map((cat, ci) => {
      const segs = cat.series.slice(0, periods).map((v, i) => {
        const y0 = offsets[i];
        offsets[i] += v;
        return { v, y0, y1: offsets[i] };
      });
      return { cat, ci, segs };
    });
  }, [categories, periods]);

  // bar geometry: evenly spaced columns on the shared time axis
  const bandW = PLOT_W / periods;
  const barW = Math.min(bandW * 0.62, 40);
  const xFor = (i: number) => PAD.l + i * bandW + (bandW - barW) / 2;

  const currentTotal = periodTotals[periods - 1] ?? 0;

  const fmtCurrency = React.useMemo(
    () => new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: 0 }),
    [currency],
  );

  // delta vs previous period (drives HighlightOnUpdate)
  const prevTotal = periodTotals[periods - 2] ?? currentTotal;
  const delta = currentTotal - prevTotal;
  const deltaPct = prevTotal ? (delta / prevTotal) * 100 : 0;
  const deltaUp = delta >= 0;

  const legendDim = (name: string) => legend != null && legend !== name;
  const activeSeg =
    active != null
      ? {
          cat: categories[active.c],
          value: categories[active.c].series[active.p] ?? 0,
          label: periodLabels[active.p],
        }
      : null;

  return (
    <div
      ref={ref}
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      style={style}
      {...props}
    >
      <div className="flex items-start justify-between gap-3">
        <div className="min-w-0">
          <span className="text-xs font-medium text-muted-foreground">
            {granularity} spend · latest period
          </span>
          <div className="mt-0.5 text-3xl font-semibold tracking-tight">
            <NumberTicker
              value={currentTotal}
              static={isStatic}
              formatOptions={{ style: "currency", currency, maximumFractionDigits: 0 }}
            />
          </div>
        </div>
        <HighlightOnUpdate
          value={currentTotal}
          color={deltaUp ? "negative" : "positive"}
          className="shrink-0"
        >
          <span
            className="flex items-center gap-0.5 rounded-md bg-secondary px-1.5 py-0.5 text-xs font-medium tabular-nums"
            style={{ color: deltaUp ? "var(--color-destructive)" : "var(--color-success)" }}
          >
            {deltaUp ? "+" : "−"}
            {Math.abs(deltaPct).toFixed(1)}%
          </span>
        </HighlightOnUpdate>
      </div>

      <div className="relative mt-3 rounded-lg border border-border bg-background p-2">
        <svg
          viewBox={`0 0 ${VB_W} ${VB_H}`}
          className="w-full"
          role="img"
          aria-label={`Stacked ${granularity.toLowerCase()} spend by category`}
        >
          {/* shared baseline */}
          <line
            x1={PAD.l}
            x2={PAD.l + PLOT_W}
            y1={baselineY}
            y2={baselineY}
            stroke="var(--color-border)"
            strokeWidth={0.75}
            vectorEffect="non-scaling-stroke"
          />

          {stacks.map(({ cat, ci, segs }) =>
            segs.map((seg, pi) => {
              if (seg.v <= 0) return null;
              const x = xFor(pi);
              const y = baselineY - hFor(seg.y1);
              const h = hFor(seg.v);
              const isActive = active?.p === pi && active?.c === ci;
              const dim =
                legendDim(cat.name) ||
                (active != null && !isActive && legend == null && active.c !== ci);
              return (
                <g key={`${ci}-${pi}`}>
                  <rect
                    x={x}
                    y={reveal ? y : baselineY}
                    width={barW}
                    height={reveal ? h : 0}
                    rx={1.5}
                    fill={cat.color}
                    fillOpacity={dim ? 0.28 : isActive ? 1 : 0.85}
                    className="transition-[y,height,fill-opacity] duration-[500ms] ease-out"
                    style={{ transitionDelay: reduced ? "0ms" : `${pi * 40}ms` }}
                  />
                  {/* focusable/hoverable hit target for the segment */}
                  <rect
                    x={x}
                    y={y}
                    width={barW}
                    height={Math.max(h, 3)}
                    fill="transparent"
                    tabIndex={0}
                    role="button"
                    aria-label={`${cat.name}, ${periodLabels[pi]}: ${fmtCurrency.format(seg.v)}`}
                    className="cursor-pointer outline-none [stroke:transparent] focus-visible:[stroke:var(--color-ring)] focus-visible:[stroke-width:2]"
                    vectorEffect="non-scaling-stroke"
                    onMouseEnter={() => setActive({ p: pi, c: ci })}
                    onMouseLeave={() => setActive(null)}
                    onFocus={() => setActive({ p: pi, c: ci })}
                    onBlur={() => setActive(null)}
                  />
                </g>
              );
            }),
          )}
        </svg>

        {activeSeg && active && (
          <div
            className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full rounded-md bg-popover px-2 py-1 text-[11px] whitespace-nowrap text-popover-foreground shadow-overlay"
            style={{
              left: `${((xFor(active.p) + barW / 2) / VB_W) * 100}%`,
              top: `${((baselineY - hFor(stacks[active.c].segs[active.p].y1)) / VB_H) * 100}%`,
            }}
          >
            <span className="inline-flex items-center gap-1.5 font-medium">
              <span
                className="size-1.5 rounded-full"
                style={{ background: activeSeg.cat.color }}
              />
              {activeSeg.cat.name}
            </span>
            <span className="ml-1.5 text-muted-foreground">{activeSeg.label}</span>
            <span className="ml-1.5 tabular-nums">
              {fmtCurrency.format(activeSeg.value)}
            </span>
          </div>
        )}

        <div className="mt-1 flex justify-between px-0.5 text-[9px] tabular-nums text-muted-foreground">
          {periodLabels.map((l) => (
            <span key={l}>{l}</span>
          ))}
        </div>
      </div>

      {/* hoverable legend — emphasize a category across all bars */}
      <div className="mt-3 flex flex-wrap gap-x-3 gap-y-1.5">
        {categories.map((c) => {
          const latest = c.series[periods - 1] ?? 0;
          return (
            <button
              key={c.name}
              type="button"
              onMouseEnter={() => setLegend(c.name)}
              onMouseLeave={() => setLegend(null)}
              onFocus={() => setLegend(c.name)}
              onBlur={() => setLegend(null)}
              className="flex items-center gap-1.5 rounded text-xs outline-none transition-[opacity] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring"
              style={{ opacity: legendDim(c.name) ? 0.4 : 1 }}
            >
              <span
                className="size-2 shrink-0 rounded-full"
                style={{ background: c.color }}
              />
              <span className="min-w-0 truncate">{c.name}</span>
              <span className="tabular-nums text-muted-foreground">
                {fmtCurrency.format(latest)}
              </span>
            </button>
          );
        })}
      </div>
    </div>
  );
}