ConsumptionChart
Interactive stacked time series over the v2 consumption metrics, with a scrubbable readout and a legend that filters.
ConsumptionChart plots the buckets the v2 consumption API returns: one series per metric, stacked into a total, oldest bucket first. Both axes are labelled, so the chart still answers questions in a screenshot. Pointer or arrow keys move a guide line and the readout names the bucket and every visible series at that moment. The legend doubles as the filter: click a series to drop it from the plot and the totals. It won’t let you turn off the last one, because an empty chart answers nothing.
storage
77.5GB-mometered through 21:40 UTC · ~15m behind
Built on Recharts through the shadcn chart primitives, so it inherits the theme’s tooltip, axis, and legend styling.
Install
npx shadcn@latest add https://ui.neon.com/r/consumption-chart.json
Usage
Values arrive in display units. Convert raw API units first with lib/consumption so the chart and the invoice agree.
import { ConsumptionChart } from "@/components/neon-ui/consumption-chart";
<ConsumptionChart
data={[
{ label: "Feb 1", values: { compute: 3.1, storage: 2.2 } },
{ label: "Feb 2", values: { compute: 2.8, storage: 2.3 } },
]}
series={[
{ id: "compute", label: "Compute", unit: "CU-hrs" },
{ id: "storage", label: "Storage", unit: "GB-mo" },
]}
granularities={["hourly", "daily", "monthly"]}
meteredThrough="metered through 21:40 UTC · ~15m behind"
/>;
Series colors default to the --chart-1 through --chart-5 theme tokens in order: one hue in five steps of lightness, with a hairline of --card between stacked bands. Stacked series separate by value and by that gap. See Chart colors. Pass color from the shared METRIC_COLORS map so a hue means the same thing here as in a StorageBreakdown beside it — a positional palette makes green mean compute on one card and root storage on the next.
Units, totals, and stacking
unit is not decoration. The header totals per unit and never across units, and stacking is disabled when the visible series don’t share one:
<ConsumptionChart
series={[
{ id: "compute", label: "Compute", unit: "CU-hrs" },
{ id: "root", label: "Root storage", unit: "GB-mo" },
]}
data={data}
/>
// header reads: 102.8 CU-hrs · 36.1 GB-mo
// series are overlaid, with a note saying why
CU-hours plus GB-months is not a quantity, and a stack whose height is that sum is a chart that lies. Give every series its unit and the component will not let you draw one.
Recharts owns the axes and the tick maths; the x-axis thins its labels to about seven so they never collide.
Variants
variant="area"(default) — reads as a trend. Best for daily and monthly windows.variant="bar"— reads as discrete buckets. Best for hourly windows, where each bar is one metered hour.stacked={false}— lays series over each other from zero instead of on top of one another, for comparing two metrics rather than summing them.
Controlled and uncontrolled
Everything works with no state of your own. Reach for the controlled props when the chart shares state with the rest of the page:
const [granularity, setGranularity] = useState<"hourly" | "daily">("daily");
<ConsumptionChart
data={granularity === "hourly" ? hourly : daily}
granularity={granularity}
granularities={["hourly", "daily"]}
onGranularityChange={setGranularity}
series={series}
/>;
The same pairing exists for the legend filter: activeSeriesIds / onActiveSeriesIdsChange, with defaultActiveSeriesIds for the uncontrolled case.
Wiring to Neon
The API key is server-only, so the browser calls your route handler, not console.neon.tech. useConsumptionHistory does the fetch and the shaping:
const { buckets, error, isLoading } = useConsumptionHistory({
from,
to,
granularity: "daily",
projectIds: [projectId],
metrics: ["compute_unit_seconds", "root_branch_bytes_month"],
});
const data = buckets.map((bucket) => ({
label: format(new Date(bucket.start)),
values: {
compute_unit_seconds: toBillingUnit(
"compute_unit_seconds",
bucket.values.compute_unit_seconds ?? 0
),
},
}));
Granularity carries a lookback limit: hourly reaches back 168 hours, daily 60 days, monthly a year. Past it the API answers 406, so keep the range switch and the granularity switch in step.
States
- Mixed units — series carrying different units are overlaid instead of stacked, with a footer note naming the units that don’t add up.
- Loading —
isLoadingskeletons the header and plot at their real heights, so the panel never resizes on arrival. - Empty — the API omits metrics that were zero, so an empty window is a real answer. Pass
emptyto word it yourself. - Error —
errorrenders the house error line above the plot. - Metering lag —
meteredThroughrenders the quiet footer notice.
Props
dataConsumptionPoint[]
Buckets oldest first; each has a label and a series-id -> value map.
ConsumptionPoint[]seriesConsumptionSeries[]
Series in stacking order; id, label, optional color and unit.
ConsumptionSeries[]variant?"area" | "bar"
"area" | "bar""area"stacked?boolean
Ignored when the visible series carry more than one unit, where stacking would sum unlike quantities.
booleantrueactiveSeriesIds?string[]
Controlled visible series.
string[]defaultActiveSeriesIds?string[]
Uncontrolled initial visible series; defaults to all.
string[]onActiveSeriesIdsChange?(ids: string[]) => void
(ids: string[]) => voidgranularity?"hourly" | "daily" | "monthly"
Controlled granularity.
"hourly" | "daily" | "monthly"granularities?("hourly" | "daily" | "monthly")[]
Granularities to offer. Omit to hide the switch.
("hourly" | "daily" | "monthly")[]onGranularityChange?(granularity) => void
(granularity) => voidtitle?string
string"consumption"action?ReactNode
Right-side header slot, e.g. a DateRangePicker.
ReactNodeformatValue?(value: number) => string
Formats the readout, legend totals, and header total.
(value: number) => stringmeteredThrough?string
Metering-lag notice rendered as a quiet footer.
stringisLoading?boolean
booleanfalseerror?string | null
string | nullempty?ReactNode
Shown when data is empty.
ReactNodeAccessibility
- The plot uses Recharts’
accessibilityLayer, so it takes focus and the arrow keys walk the buckets with the tooltip following. - Legend entries are real buttons carrying
aria-pressed; the granularity switch is a labelledfieldsetof pressed buttons. - Legend entries are real buttons carrying
aria-pressed; the granularity switch is arole="group"of pressed buttons. - The SVG is
aria-hidden, so screen readers get the readout text rather than a wall of paths. - The error line is
role="alert".