neon-ui
componentconsumption-chartsource: ok · hit
ConsumptionChart
Interactive consumption time series with labelled axes, unit-aware totals, a scrubbable readout, and a legend that filters.
npx shadcn add "https://ui.neon.com/r/consumption-chart.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumchartsrecharts
Registry dependencies
utilshttps://ui.neon.com/r/neon-tokens.jsonhttps://ui.neon.com/r/skeleton.jsonhttps://ui.neon.com/r/chart.json
Files2 · source cached 10/8/2026, 2:12:42 PM
src/components/consumption-chart/consumption-chart.tsx
"use client";
import type { ComponentProps, ReactNode } from "react";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
XAxis,
YAxis,
} from "recharts";
import type { ChartConfig } from "@/components/ui/chart";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { Skeleton } from "@/components/ui/skeleton";
import { useControllableState } from "@/hooks/use-controllable-state";
import { cn } from "@/lib/utils";
export type ConsumptionGranularityOption = "hourly" | "daily" | "monthly";
export interface ConsumptionSeries {
/** Stable id, e.g. "compute_unit_seconds". Keys into each point's values. */
id: string;
/** Legend and tooltip label. */
label: string;
/** Any CSS color; defaults to the --chart-n token for its position. */
color?: string;
/**
* Unit of the values, e.g. "CU-hrs" or "GB-mo". Series that don't share
* a unit are never stacked or summed together — CU-hours plus GB-months
* is not a quantity.
*/
unit?: string;
}
export interface ConsumptionPoint {
/** Axis and tooltip label, e.g. "Feb 4" or "14:00". */
label: string;
/** Series id -> value, already converted to the display unit. */
values: Record<string, number>;
}
export type ConsumptionChartProps = Omit<
ComponentProps<"section">,
"children" | "onChange"
> & {
/** Buckets, oldest first. */
data: ConsumptionPoint[];
/** Series to plot, in stacking order (first sits at the bottom). */
series: ConsumptionSeries[];
/** "area" reads as a trend, "bar" as discrete buckets. */
variant?: "area" | "bar";
/**
* Stack series into a total. Ignored when the visible series carry more
* than one unit, where stacking would be arithmetic on unlike things.
*/
stacked?: boolean;
/** Controlled visible series. */
activeSeriesIds?: string[];
/** Uncontrolled initial visible series; defaults to all of them. */
defaultActiveSeriesIds?: string[];
onActiveSeriesIdsChange?: (ids: string[]) => void;
/** Controlled granularity; pair with `granularities` to show the switch. */
granularity?: ConsumptionGranularityOption;
defaultGranularity?: ConsumptionGranularityOption;
onGranularityChange?: (granularity: ConsumptionGranularityOption) => void;
/** Granularities to offer. Omit to hide the switch. */
granularities?: ConsumptionGranularityOption[];
/** Panel heading. */
title?: string;
/** Right-side header slot, e.g. a DateRangePicker. Sits before the switch. */
action?: ReactNode;
/** Value formatter for the axis, readout, and legend totals. */
formatValue?: (value: number) => string;
/** Quiet footer, e.g. a metering-lag notice. */
meteredThrough?: string;
isLoading?: boolean;
error?: string | null;
/** Shown when `data` is empty. */
empty?: ReactNode;
};
/* ─────────────────────────────────────────────────────────
* CHART STORYBOARD
*
* rest Recharts draws the plot; both axes are
* labelled, so the chart still answers questions
* in a screenshot
* color one hue, five steps of lightness, and a
* hairline of --card between stacked bands.
* Bands separate by value and by that gap, which
* survives at any size — texture did not
* totals the header sums per unit, never across them.
* CU-hours and GB-months don't add up, and one
* number pretending they do is worse than none
* stacking only within a unit. Mixed units fall back to
* overlaid series and say so
* scrub Recharts' accessibility layer gives the plot
* focus and arrow-key movement; the tooltip
* names the bucket and every visible series
* toggle the legend is the filter. The last visible
* series won't turn itself off, because an empty
* chart answers nothing
* ───────────────────────────────────────────────────────── */
const PALETTE = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
] as const;
/**
* Bands are tinted, not solid: a stack of fully saturated fills reads as
* slabs of paint and the card stops looking like a chart. The series
* color goes on the top edge instead, where it draws the boundary.
*/
const BAND_FILL_BOTTOM = 0.5;
const BAND_FILL_TOP = 0.24;
const OVERLAY_FILL = 0.16;
const BAND_STROKE = 2;
/**
* Bands fade as they stack. Depth is then encoded twice, by the ramp and
* by weight, so two neighbouring greens can't collapse into one mass.
*/
const fillAt = (index: number, count: number) => {
if (count <= 1) {
return BAND_FILL_BOTTOM;
}
const ratio = index / (count - 1);
return BAND_FILL_BOTTOM - ratio * (BAND_FILL_BOTTOM - BAND_FILL_TOP);
};
const AXIS_TICK_COUNT = 5;
const MAX_X_LABELS = 7;
const COMPACT = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
notation: "compact",
});
const defaultFormat = (value: number) => COMPACT.format(value);
const colorOf = (series: ConsumptionSeries, index: number): string =>
series.color ?? PALETTE[index % PALETTE.length] ?? PALETTE[0];
/** Distinct units across the visible series; "" counts as its own unit. */
const unitsOf = (visible: ConsumptionSeries[]) => [
...new Set(visible.map((item) => item.unit ?? "")),
];
/**
* Sums per unit, never across. A chart of CU-hours and GB-months reads
* "102.8 CU-hrs · 77.5 GB-mo" rather than one meaningless number.
*/
const totalsByUnit = (
visible: ConsumptionSeries[],
totals: Record<string, number>
): [string, number][] => {
const byUnit = new Map<string, number>();
for (const item of visible) {
const unit = item.unit ?? "";
byUnit.set(unit, (byUnit.get(unit) ?? 0) + (totals[item.id] ?? 0));
}
return [...byUnit.entries()];
};
const HeadlineTotals = ({
formatValue,
totals,
}: {
formatValue: (value: number) => string;
totals: [string, number][];
}) => (
<>
{totals.map(([unit, total], index) => (
<span className="flex items-baseline gap-1" key={unit}>
{index > 0 ? (
<span className="text-muted-foreground/40 text-xs">·</span>
) : null}
<span className="font-medium font-mono text-foreground text-sm tabular-nums">
{formatValue(total)}
</span>
{unit ? (
<span className="font-mono text-[10px] text-muted-foreground">
{unit}
</span>
) : null}
</span>
))}
</>
);
const LegendItem = ({
color,
formatValue,
isActive,
onToggle,
series,
total,
}: {
color: string;
formatValue: (value: number) => string;
isActive: boolean;
onToggle: () => void;
series: ConsumptionSeries;
total: number;
}) => (
<button
aria-pressed={isActive}
className={cn(
"flex items-baseline gap-1.5 rounded-md px-1.5 py-1 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
!isActive && "opacity-45"
)}
onClick={onToggle}
type="button"
>
<span
aria-hidden="true"
className={cn(
"size-2.5 shrink-0 translate-y-[1px] rounded-[2px]",
!isActive && "ring-1 ring-muted-foreground/40"
)}
style={{ background: isActive ? color : "transparent" }}
/>
<span className="font-mono text-[11px] text-muted-foreground">
{series.label}
</span>
<span className="font-medium font-mono text-[11px] text-foreground tabular-nums">
{formatValue(total)}
</span>
{series.unit ? (
<span className="font-mono text-[10px] text-muted-foreground/60">
{series.unit}
</span>
) : null}
</button>
);
/* The pill slides between options over 220ms; the movement is the
explanation, so the reader sees which of two things they picked. */
const SWITCH_MS = 220;
const SWITCH_EASE = "cubic-bezier(0.32, 0.72, 0, 1)";
const SWITCH_PAD = 4;
const GranularitySwitch = ({
onChange,
options,
value,
}: {
onChange: (next: ConsumptionGranularityOption) => void;
options: ConsumptionGranularityOption[];
value: ConsumptionGranularityOption;
}) => {
const index = Math.max(0, options.indexOf(value));
return (
<fieldset
aria-label="Granularity"
className="relative grid grid-flow-col items-center rounded-full border border-border/60 p-0.5"
style={{ gridAutoColumns: "1fr" }}
>
<span
aria-hidden="true"
className="absolute inset-y-0.5 left-0.5 rounded-full bg-primary/10 motion-reduce:transition-none"
style={{
transform: `translateX(${index * 100}%)`,
transition: `transform ${SWITCH_MS}ms ${SWITCH_EASE}`,
width: `calc((100% - ${SWITCH_PAD}px) / ${options.length})`,
}}
/>
{options.map((option) => (
<button
aria-pressed={option === value}
className={cn(
"relative z-10 rounded-full px-2 py-0.5 text-center font-mono text-[10px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 motion-reduce:transition-none",
option === value
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
key={option}
onClick={() => onChange(option)}
style={{ transition: `color ${SWITCH_MS}ms ${SWITCH_EASE}` }}
type="button"
>
{option}
</button>
))}
</fieldset>
);
};
/** Everything the plot and the header need, derived from props. */
const shapeChart = (
data: ConsumptionPoint[],
series: ConsumptionSeries[],
visible: ConsumptionSeries[]
) => {
const rows = data.map((point) => ({ ...point.values, label: point.label }));
const totals: Record<string, number> = {};
for (const item of series) {
let sum = 0;
for (const point of data) {
sum += point.values[item.id] ?? 0;
}
totals[item.id] = sum;
}
const config: ChartConfig = Object.fromEntries(
series.map((item, index) => [
item.id,
{ color: colorOf(item, index), label: item.label },
])
);
return {
config,
headlineTotals: totalsByUnit(visible, totals),
rows,
totals,
xInterval: Math.max(0, Math.ceil(rows.length / MAX_X_LABELS) - 1),
};
};
/**
* ChartTooltipContent's `formatter` replaces the entire row, swatch and
* label included, so a formatter that returns just a number leaves the
* reader with three bare values and no idea which series is which.
*/
const TooltipRow = ({
color,
label,
unit,
value,
}: {
color: string;
label: string;
unit?: string;
value: string;
}) => (
<>
<span
aria-hidden="true"
className="size-2.5 shrink-0 translate-y-[1px] rounded-[2px]"
style={{ background: color }}
/>
<div className="flex flex-1 items-center justify-between gap-3 leading-none">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium font-mono text-foreground tabular-nums">
{value}
{unit ? (
<span className="ml-1 font-normal text-muted-foreground/70">
{unit}
</span>
) : null}
</span>
</div>
</>
);
/** Builds the tooltip row renderer for a given series list. */
const tooltipFormatter = (
series: ConsumptionSeries[],
formatValue: (value: number) => string
) =>
function TooltipRowRenderer(
value: unknown,
name: unknown,
entry: { color?: string }
) {
const item = series.find((candidate) => candidate.id === name);
return (
<TooltipRow
color={String(entry?.color ?? "var(--chart-1)")}
label={item?.label ?? String(name)}
unit={item?.unit}
value={formatValue(Number(value))}
/>
);
};
/** The plot itself: axes, marks, tooltip. */
const Plot = ({
colorFor,
config,
formatValue,
granularityKey,
isStacked,
rows,
series,
variant,
visible,
xInterval,
}: {
colorFor: (item: ConsumptionSeries) => string;
config: ChartConfig;
formatValue: (value: number) => string;
granularityKey: string;
isStacked: boolean;
rows: Record<string, number | string>[];
series: ConsumptionSeries[];
variant: "area" | "bar";
visible: ConsumptionSeries[];
xInterval: number;
}) => {
const Chart = variant === "bar" ? BarChart : AreaChart;
return (
<ChartContainer
className="aspect-auto h-[220px] w-full"
config={config}
// Re-mounts on granularity change so the new dataset animates in
// rather than snapping between two unrelated shapes.
key={granularityKey}
>
<Chart accessibilityLayer data={rows} margin={{ left: 4, right: 8 }}>
<CartesianGrid
stroke="var(--border)"
strokeOpacity={0.5}
vertical={false}
/>
<XAxis
axisLine={false}
dataKey="label"
interval={xInterval}
tickLine={false}
tickMargin={8}
/>
<YAxis
axisLine={false}
tickCount={AXIS_TICK_COUNT}
tickFormatter={formatValue}
tickLine={false}
width={44}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={tooltipFormatter(series, formatValue)}
/>
}
cursor={{ stroke: "var(--border)", strokeDasharray: "2 3" }}
/>
{visible.map((item, index) =>
variant === "bar" ? (
<Bar
dataKey={item.id}
fill={colorFor(item)}
fillOpacity={isStacked ? fillAt(index, visible.length) : 0.4}
key={item.id}
radius={2}
stackId={isStacked ? "a" : undefined}
stroke={colorFor(item)}
strokeWidth={1}
/>
) : (
<Area
dataKey={item.id}
fill={colorFor(item)}
fillOpacity={
isStacked ? fillAt(index, visible.length) : OVERLAY_FILL
}
key={item.id}
stackId={isStacked ? "a" : undefined}
// The series color rides the top edge of its own band, so
// neighbours separate by line rather than by slab.
stroke={colorFor(item)}
strokeWidth={BAND_STROKE}
type="monotone"
/>
)
)}
</Chart>
</ChartContainer>
);
};
export const ConsumptionChart = ({
action,
activeSeriesIds,
className,
data,
defaultActiveSeriesIds,
defaultGranularity = "daily",
empty,
error = null,
formatValue = defaultFormat,
granularities,
granularity,
isLoading = false,
meteredThrough,
onActiveSeriesIdsChange,
onGranularityChange,
series,
stacked = true,
title = "consumption",
variant = "area",
...props
}: ConsumptionChartProps) => {
const [activeIds, setActiveIds] = useControllableState<string[]>({
caller: "ConsumptionChart",
defaultProp: defaultActiveSeriesIds ?? series.map((item) => item.id),
onChange: onActiveSeriesIdsChange,
prop: activeSeriesIds,
});
const [activeGranularity, setActiveGranularity] =
useControllableState<ConsumptionGranularityOption>({
caller: "ConsumptionChart",
defaultProp: defaultGranularity,
onChange: onGranularityChange,
prop: granularity,
});
const visible = series.filter((item) => activeIds.includes(item.id));
const units = unitsOf(visible);
// Stacking adds values together. Across units that is not a quantity, so
// overlay instead and say why rather than drawing a meaningless height.
const isMixedUnits = units.length > 1;
const isStacked = stacked && !isMixedUnits;
// Recharts wants one flat row per bucket, keyed by series id.
const { config, headlineTotals, rows, totals, xInterval } = shapeChart(
data,
series,
visible
);
const toggleSeries = (id: string) => {
if (activeIds.length === 1 && activeIds[0] === id) {
return;
}
setActiveIds(
activeIds.includes(id)
? activeIds.filter((item) => item !== id)
: series
.filter((item) => item.id === id || activeIds.includes(item.id))
.map((item) => item.id)
);
};
if (isLoading) {
return (
<section
className={cn(
"rounded-lg border border-border/60 bg-card p-4",
className
)}
data-slot="consumption-chart"
{...props}
>
<Skeleton className="h-4 w-32" />
<Skeleton className="mt-4 h-[220px] w-full" />
<Skeleton className="mt-4 h-4 w-64" />
</section>
);
}
return (
<section
className={cn(
"rounded-lg border border-border/60 bg-card p-4",
className
)}
data-slot="consumption-chart"
{...props}
>
<header className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<h3 className="font-mono text-muted-foreground text-xs">{title}</h3>
<HeadlineTotals formatValue={formatValue} totals={headlineTotals} />
</div>
<div className="flex items-center gap-2">
{action}
{granularities?.length ? (
<GranularitySwitch
onChange={setActiveGranularity}
options={granularities}
value={activeGranularity}
/>
) : null}
</div>
</header>
{error ? (
<p
className="mb-3 flex items-baseline gap-2 text-sm"
data-slot="consumption-chart-error"
role="alert"
>
<span className="font-mono text-destructive text-xs">error</span>
<span className="text-foreground">{error}</span>
</p>
) : null}
{rows.length === 0 ? (
<div className="flex h-[220px] items-center justify-center rounded-md border border-border/50 border-dashed">
{empty ?? (
<p className="font-mono text-[11px] text-muted-foreground">
no consumption in this window
</p>
)}
</div>
) : (
<Plot
colorFor={(item) => colorOf(item, series.indexOf(item))}
config={config}
formatValue={formatValue}
granularityKey={activeGranularity}
isStacked={isStacked}
rows={rows}
series={series}
variant={variant}
visible={visible}
xInterval={xInterval}
/>
)}
<div className="mt-3 flex flex-wrap items-center gap-x-1 gap-y-0.5 border-border/50 border-t pt-3">
{series.map((item, index) => (
<LegendItem
color={colorOf(item, index)}
formatValue={formatValue}
isActive={activeIds.includes(item.id)}
key={item.id}
onToggle={() => toggleSeries(item.id)}
series={item}
total={totals[item.id] ?? 0}
/>
))}
</div>
{isMixedUnits && stacked ? (
<p
className="mt-2 text-[10px] text-muted-foreground/70"
data-slot="consumption-chart-units"
>
series overlaid, not stacked · {units.filter(Boolean).join(" and ")}{" "}
don't add up
</p>
) : null}
{meteredThrough ? (
<p
className="mt-2 text-[10px] text-muted-foreground/70 tabular-nums"
data-slot="consumption-chart-lag"
>
{meteredThrough}
</p>
) : null}
</section>
);
};
src/hooks/use-controllable-state.tsx
/* oxlint-disable func-style, no-empty-function, no-shadow, no-use-before-define, react/react-compiler, max-lines-per-function, max-lines, no-magic-numbers -- vendored from chanhdai.com/r/elastic-slider.json (MIT, @iamncdai); kept diffable against upstream */
import * as React from "react";
// use-layout-effect.tsx
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-layout-effect/src/use-layout-effect.tsx
/**
* On the server, React emits a warning when calling `useLayoutEffect`.
* This is because neither `useLayoutEffect` nor `useEffect` run on the server.
* We use this safe version which suppresses the warning by replacing it with a noop on the server.
*
* See: https://reactjs.org/docs/hooks-reference.html#uselayouteffect
*/
const useLayoutEffect = globalThis?.document ? React.useLayoutEffect : () => {};
// use-controllable-state.tsx
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/use-controllable-state.tsx
// Prevent bundlers from trying to optimize the import
const useInsertionEffect: typeof useLayoutEffect =
(React as never)[" useInsertionEffect ".trim().toString()] || useLayoutEffect;
type ChangeHandler<T> = (state: T) => void;
type SetStateFn<T> = React.Dispatch<React.SetStateAction<T>>;
interface UseControllableStateParams<T> {
prop?: T | undefined;
defaultProp: T;
onChange?: ChangeHandler<T>;
caller?: string;
}
export function useControllableState<T>({
prop,
defaultProp,
onChange = () => {},
caller,
}: UseControllableStateParams<T>): [T, SetStateFn<T>] {
const [uncontrolledProp, setUncontrolledProp, onChangeRef] =
useUncontrolledState({
defaultProp,
onChange,
});
const isControlled = prop !== undefined;
const value = isControlled ? prop : uncontrolledProp;
// Hooks run unconditionally so Hook order never changes between renders;
// only the dev-time warning itself is gated on the environment.
// (Neon UI patch on the vendored source.)
const isControlledRef = React.useRef(prop !== undefined);
React.useEffect(() => {
if (process.env.NODE_ENV !== "production") {
const wasControlled = isControlledRef.current;
if (wasControlled !== isControlled) {
const from = wasControlled ? "controlled" : "uncontrolled";
const to = isControlled ? "controlled" : "uncontrolled";
console.warn(
`${caller} is changing from ${from} to ${to}. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.`
);
}
}
isControlledRef.current = isControlled;
}, [isControlled, caller]);
const setValue = React.useCallback<SetStateFn<T>>(
(nextValue) => {
if (isControlled) {
const value = isFunction(nextValue) ? nextValue(prop) : nextValue;
if (value !== prop) {
onChangeRef.current?.(value);
}
} else {
// Notify the parent from the event handler instead of a useEffect,
// per https://react.dev/learn/you-might-not-need-an-effect — saves
// the extra render. (Neon UI patch on the vendored source.)
const value = isFunction(nextValue)
? nextValue(uncontrolledProp)
: nextValue;
setUncontrolledProp(value);
if (value !== uncontrolledProp) {
onChangeRef.current?.(value);
}
}
},
[isControlled, prop, setUncontrolledProp, onChangeRef, uncontrolledProp]
);
return [value, setValue];
}
function useUncontrolledState<T>({
defaultProp,
onChange,
}: Omit<UseControllableStateParams<T>, "prop">): [
Value: T,
setValue: React.Dispatch<React.SetStateAction<T>>,
OnChangeRef: React.RefObject<ChangeHandler<T> | undefined>,
] {
const [value, setValue] = React.useState(defaultProp);
const onChangeRef = React.useRef(onChange);
useInsertionEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
// onChange is fired from setValue in useControllableState rather than from
// an effect here, so parents update in the same render pass.
// (Neon UI patch on the vendored source.)
return [value, setValue, onChangeRef];
}
function isFunction(value: unknown): value is (...args: never[]) => unknown {
return typeof value === "function";
}