A cloud-spend view with a stacked area chart by service, a month-to-date total that counts up on reveal, and top movers.
npx shadcn@latest add @paragon/cost-explorerAlso installs: number-ticker, highlight-on-update
"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>
);
}