StorageBreakdown
What your storage bill is made of — root, child, instant restore, and snapshots — as one stacked bar with per-segment shares.
Storage is four line items on a Neon invoice, and they behave nothing alike: root branches hold the real bytes, child branches hold only their delta from the parent, instant restore holds your PITR window, and snapshots hold whatever your schedule kept. StorageBreakdown puts them in one bar so “what is eating my storage” is a glance, and lists them underneath with values and shares so “how much” doesn’t need a tooltip. Hovering a row dims the other segments; the bar itself never moves, so comparison stays honest.
They also bill at four different rates, so bytes and dollars rank differently. Give each segment its rate and the card offers a volume / cost switch: instant restore holds 15.3% of the volume in the demo above but only 9.7% of the cost, and snapshots drop from 4.0% to 1.1%. Ranking by bytes on a billing page points the reader at the wrong lever.
storage
31.68GB-mo· ~$10.03
- Root branchesmain and other root branches18.42~$6.4558.1%
- Child branchesdelta from each branch's parent7.13~$2.5022.5%
- Instant restore7-day point-in-time restore window4.86~$0.9715.3%
- Snapshots1 manual, 6 scheduled1.27~$0.114.0%
Install
npx shadcn@latest add https://ui.neon.com/r/storage-breakdown.json
Usage
import { StorageBreakdown } from "@/components/neon-ui/storage-breakdown";
<StorageBreakdown
segments={[
{ id: "root", label: "Root branches", value: 18.42 },
{
id: "child",
label: "Child branches",
value: 7.13,
hint: "delta from parent",
},
{ id: "restore", label: "Instant restore", value: 4.86 },
{ id: "snapshots", label: "Snapshots", value: 1.27 },
]}
totalLabel="GB-months · Feb 1 – Feb 14"
/>;
Pass total to change the denominator behind the share percentages. Left unset it’s the segment sum, which reads as composition. Set it to a plan allowance and the same bar reads as headroom.
Wiring to Neon
The four metrics come back as byte-hours. Divide by 744 and 10^9 for GB-months, the unit the invoice charges in — toGbMonths does it:
import {
flattenConsumption,
METRIC_LABELS,
STORAGE_METRICS,
sumBuckets,
toGbMonths,
} from "@/lib/consumption";
const { data } = await neon.consumption
.perProjectV2({
org_id: orgId,
project_ids: [projectId],
from,
to,
granularity: "daily",
metrics: [...STORAGE_METRICS],
})
.all();
const totals = sumBuckets(flattenConsumption(data?.[0]?.periods ?? []));
const segments = STORAGE_METRICS.map((metric) => ({
id: metric,
label: METRIC_LABELS[metric],
rate: PLAN_RATES[plan][metric],
value: toGbMonths(totals[metric] ?? 0),
}));
Seed every segment at zero rather than mapping only what came back. The API omits metrics whose value was zero, and a quiet month should read as quiet, not as a broken request.
For a point-in-time size instead of a billing figure, use toAverageGb(byteHours, hoursBetween(from, to)). That’s the number the Neon Console shows, and it will not match the invoice.
States
- Hover and focus — highlights a row and dims the rest. Use
activeSegmentId/onActiveSegmentIdChangeto drive it from elsewhere, e.g. a chart legend. - Clickable — pass
onSelectSegmentto make rows actionable, e.g. to filter a chart. Without it rows render as plaindivs, because a row that does nothing shouldn’t be a tab stop. - Loading —
isLoadingskeletons the total, bar, and one row per segment. - Error —
errorrenders the house error line under the total.
Props
segmentsStorageSegment[]
Stacking order; id, label, value, optional rate, color, and hint.
StorageSegment[]title?string
string"storage"unit?string
Unit for the headline figure; sits beside the number.
string"GB-mo"period?string
Period readout in the header, e.g. "Feb 1 – Feb 14".
stringtotal?number
Share denominator in volume view; defaults to the segment sum.
numberview?"volume" | "cost"
Controlled ranking. Needs a rate on every segment.
"volume" | "cost"defaultView?"volume" | "cost"
"volume" | "cost""volume"onViewChange?(view) => void
(view) => voidformatValue?(value: number) => string
Formats every number shown. Defaults to two decimals.
(value: number) => stringactiveSegmentId?string | null
Controlled highlight.
string | nullonActiveSegmentIdChange?(id: string | null) => void
(id: string | null) => voidonSelectSegment?(segment: StorageSegment) => void
Makes rows clickable.
(segment: StorageSegment) => voidisLoading?boolean
booleanfalseerror?string | null
string | nullfooter?ReactNode
ReactNodeAccessibility
- Rows are buttons only when
onSelectSegmentis set. Otherwise they’redivs: four focusable buttons that do nothing is a worse experience than a hover-only highlight. - The view switch is a labelled
fieldsetofaria-pressedbuttons. - The bar is
aria-hidden; every value it encodes is stated in the rows beneath it. - Percentages always print one decimal, so the column aligns on the decimal point rather than ragging at
4%next to58.1%. - Segments below 0.4% are dropped from the bar (they’d render as a sliver) but keep their row and percentage.
- The error line is
role="alert".