Skip to content
Neon UI
Esc
navigateopen⌘Jpreview
On this page

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

Feb 1 – Feb 14

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 / onActiveSegmentIdChange to drive it from elsewhere, e.g. a chart legend.
  • Clickable — pass onSelectSegment to make rows actionable, e.g. to filter a chart. Without it rows render as plain divs, because a row that does nothing shouldn’t be a tab stop.
  • LoadingisLoading skeletons the total, bar, and one row per segment.
  • Errorerror renders the house error line under the total.

Props

PropType
segmentsStorageSegment[]

Stacking order; id, label, value, optional rate, color, and hint.

TypeStorageSegment[]
title?string
Typestring
Default"storage"
unit?string

Unit for the headline figure; sits beside the number.

Typestring
Default"GB-mo"
period?string

Period readout in the header, e.g. "Feb 1 – Feb 14".

Typestring
total?number

Share denominator in volume view; defaults to the segment sum.

Typenumber
view?"volume" | "cost"

Controlled ranking. Needs a rate on every segment.

Type"volume" | "cost"
defaultView?"volume" | "cost"
Type"volume" | "cost"
Default"volume"
onViewChange?(view) => void
Type(view) => void
formatValue?(value: number) => string

Formats every number shown. Defaults to two decimals.

Type(value: number) => string
activeSegmentId?string | null

Controlled highlight.

Typestring | null
onActiveSegmentIdChange?(id: string | null) => void
Type(id: string | null) => void
onSelectSegment?(segment: StorageSegment) => void

Makes rows clickable.

Type(segment: StorageSegment) => void
isLoading?boolean
Typeboolean
Defaultfalse
error?string | null
Typestring | null
footer?ReactNode
TypeReactNode

Accessibility

  • Rows are buttons only when onSelectSegment is set. Otherwise they’re divs: four focusable buttons that do nothing is a worse experience than a hover-only highlight.
  • The view switch is a labelled fieldset of aria-pressed buttons.
  • 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 to 58.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".