Model consumption
1,134,000 pt across 6 models, this month
"use client";
import {Installation
$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/breakdown.json
Usage
import {
Breakdown,
BreakdownBar,
BreakdownLegend,
breakdownColors,
} from "@/components/ui/breakdown";
/** Every model the workspace can spend on — the catalogue, not the month. */
const MODELS = [
{ key: "seedance-2-5", label: "Seedance 2.5", value: 486_200 },
{ key: "claude-sonnet-4-5", label: "Claude Sonnet 4.5", value: 312_450 },
{ key: "veo-3-1", label: "Veo 3.1", value: 184_900 },
];
const COLORS = breakdownColors(MODELS.map((model) => model.key));
export function ModelConsumption() {
return (
<Breakdown
colors={COLORS}
formatValue={(value) => `${value.toLocaleString()} pt`}
items={MODELS}
>
<div className="flex flex-col gap-4">
<BreakdownBar label="Consumption by model" />
<BreakdownLegend />
</div>
</Breakdown>
);
}The order of items is the caller's and the component never sorts. A breakdown is a
ranking as often as it is a share, and a list that reorders itself under the reader is
worse than one that is merely wrong.
Bar and legend
They answer two different questions, which is why they are two parts rather than one picture. The bar answers what shape is this — read in one glance, before anything is named. The legend answers which part is that — the colour matched to a label and an amount.
The root holds what both need and draws nothing itself: the shares, the colour of each part, and the two formatters. Neither part can see the other, and both have to agree on all four, so the root is the only place they can be worked out once.
// The legend alone is a list.
<Breakdown items={models}>
<BreakdownLegend />
</Breakdown>
// The bar alone is a strip inside somebody else's row.
<Breakdown items={models}>
<BreakdownBar />
</Breakdown>A part with nothing in it is dropped rather than drawn at zero width — a cut you cannot see reads as a rendering fault, not as a part with no share. That goes for the legend too, so the two always name the same set.
The parts and the whole
| Prop | Default | Does |
|---|---|---|
items | — | The parts, in the order they should be drawn |
colors | — | Each part's colour, keyed by key |
total | sum of items | The whole the shares are read against |
total is for the case where the parts on screen are a slice of something larger — a page
of a longer list, a period that includes spend the caller has already filtered out. The
bar does not fill its track when the parts do not add up to total; the track showing
through at the end is the reading.
Colour
colors is required, and the component draws no colour of its own. A palette is a statement
about what the parts mean — which model is which — and that is not something the
component can know: it sees this period's parts and nothing else. A colour it invented
would be a guess at an identity it cannot see, and the guess would be wrong in the quietest
way there is. Two months read side by side, and the same model is a different hue in each,
because it moved up the ranking. A default that is wrong quietly is worse than no default,
so there is none.
breakdownColors is that guess, offered as a function instead of hidden as a default:
const COLORS = breakdownColors(CATALOGUE.map((model) => model.key));It hands out --chart-1 … --chart-5 in order — the sequence the theme already publishes
for exactly this, so dark mode and a theme swap come free — and past five it walks the hues
again with a lightness step mixed toward --foreground. Toward the ink, never toward the
surface: a step toward the surface moves a part into whatever it is drawn on, which
brightens it on a light theme and darkens it on a dark one, so the ramp would invert with
the theme. Fifteen parts are distinguishable this way, past the point a stacked bar can be
read at all.
Hand it the catalogue, not the page:
// The model keeps its colour in a month it is small in, or absent from.
const COLORS = breakdownColors(CATALOGUE.map((model) => model.key));
// A colour per rank — the hues reshuffle whenever the ranking does.
const COLORS = breakdownColors(SPEND.map((row) => row.key));Hashing the key into a slot is the other tempting shortcut, and it is worse. Six parts over the fifteen slots collide about two times in three, and two parts in one colour is the one thing a stacked bar cannot survive: identical dots in the legend, and two cuts that read as one. The sequence cannot collide — consecutive positions are consecutive hues by construction.
When the palette is yours to name — a brand colour per model, a status colour per member — write the map out. Anything CSS accepts works, and a token is the answer almost every time: the theme already names the roles, and a literal hex is a guess about the surface it will be drawn on.
const COLORS = {
"seedance-2-5": "var(--chart-1)",
"claude-sonnet-4-5": "var(--chart-2)",
"veo-3-1": "var(--chart-3)",
};A key with no colour in the map is drawn in --muted-foreground — a neutral belonging to
no sequence, so a part nobody coloured reads as a part nobody coloured rather than as a
member of the palette.
That fallback is a report, not a repair. Falling back to --chart-1 would claim a rank the
part does not have; falling back to nothing would be quieter still, and a dot missing from
the legend is exactly the kind of omission that survives a review. The cut is the wrong
colour on purpose, where it can be seen. It also covers the caller whose parts arrive from
a query and whose palette cannot name them in advance — an uncoloured part is a legitimate
runtime state, not always a forgotten line.
Formatting
| Prop | Default | Does |
|---|---|---|
formatValue | 12,400 | The amount beside a label |
formatShare | 42% of total | The line in the tooltip |
<Breakdown
formatShare={(share) => `${Math.round(share)}% of the workspace`}
formatValue={(value) => `${value.toLocaleString()} pt`}
items={models}
/>Both defaults are fixed to en-US rather than read from the browser, for the reason the
heatmap's locale is fixed: the server and the client have to render the same string, and
a number that gains a separator on hydration is a mismatch. Both are props for the caller
who wants their own language.
Shares are exact where they are drawn and rounded where they are printed. A bar cut to
Math.round would collapse its smallest parts to nothing and leave a gap at the end of the
track; a percentage printed to four decimals is noise.
Hover
The share is not printed beside the amount. The bar already carries the comparison, and a column of percentages next to it is the same fact said twice — so the exact number waits in the tooltip, where it costs nothing until it is asked for.
It is in the accessible text either way, because a tooltip is a pointer's affordance and a screen reader has none.
A frame of your own
useBreakdown hands over what the parts read: the slices, with their colours and shares
filled in, and the two formatters. A row per part, a second legend, a bar drawn inside a
table cell — all of them are the same maths in a different frame.
"use client";
import {const Rows = () => {
const { formatValue, items } = useBreakdown();
return items.map((item) => (
<div key={item.key}>
{item.label}
<span style={{ background: item.color, width: `${item.share}%` }} />
{formatValue(item.value)}
</div>
));
};
<Breakdown items={models}>
<Rows />
</Breakdown>;Nothing to draw
With no parts — or none with a value above zero — both parts render nothing at all. An empty state is the host's, because the host is the only one that knows which of the two it has: a period with no spend, or a request that has not come back yet. A surface that is still waiting is Agent Indicator.
Accessibility
The bar is a role="img" whose label names every part and its amount, so the whole picture
is one announcement rather than a row of unlabelled spans. Pass label to put a sentence
in front of it:
<BreakdownBar label="Consumption by model" />The legend is real text — a label and an amount, both readable — with the share in an
sr-only span so nothing lives only in a hover. The colour dot is aria-hidden: it is the
same information the label already carries, and a screen reader has no use for it.
The legend is not focusable. It is a reading, not a control, and turning six rows into six tab stops to reach a tooltip that repeats what the text already says is a worse deal than leaving it out of the tab order.
Component source
"use client";
import type { ReactNode } from "react";
import { createContext, useContext, useMemo } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/* -- A whole, as its parts -----------------------------------------------------
* Where did it all go. One bar, cut into the parts that make it up, each cut as wide
* as its share; under it, a legend naming every part and its amount. The bar answers
* "what is the shape of this" before anything is read, and the legend answers "which
* part is that" — which is why the two are separate parts and not one picture.
*
* The root derives, the parts draw. Shares, the colour of each part and the two
* formatters are worked out once in the root, because the bar and the legend must agree
* on all four and neither can see the other. Everything else — where they sit, whether
* one of them is there at all — belongs to the caller: the legend alone is a list, the
* bar alone is a strip inside somebody else's row.
*
* Nothing here loads, fails, or is empty in its own way. A surface that is still waiting
* has Waiting Row; a surface with nothing to show knows it before this component does.
* --------------------------------------------------------------------------- */
/** One part of the whole. `key` is what React and the caller's own lookups use — and what
* `colors` is keyed by; `label` is what a person reads. */
export interface BreakdownDatum {
key: string;
label: string;
value: number;
}
/** A datum with the two things the root derives filled in: the colour it will be drawn
* in, and its share of the whole, 0–100 and unrounded. */
export interface BreakdownSlice extends BreakdownDatum {
color: string;
share: number;
}
/** What a part reads from the root. Exported with `useBreakdown` so a caller can build a
* frame of their own — a row per part, a second legend — without redoing the maths. */
export interface BreakdownState {
/** The tooltip's line. Defaults to `42% of total`. */
formatShare: (share: number) => string;
/** The amount beside a label. Defaults to `12,400`. */
formatValue: (value: number) => string;
/** The parts with something in them, in the caller's order. */
items: BreakdownSlice[];
}
export interface BreakdownProps {
children: ReactNode;
/** Each part's colour, keyed by `key` — any CSS colour, usually a token. Required, and
* deliberately so; the note under Colour says why. */
colors: Record<string, string>;
formatShare?: (share: number) => string;
formatValue?: (value: number) => string;
/** The parts, in the order they should be drawn. */
items: BreakdownDatum[];
/** The whole the shares are read against. Defaults to the sum of `items`, and is
* passed when the caller is showing a page of something larger. */
total?: number;
}
export interface BreakdownBarProps {
className?: string;
/** Names the bar for a screen reader. Defaults to the parts and their amounts, which
* is what the bar is; a sentence in front of them is what a page usually wants. */
label?: string;
}
export interface BreakdownLegendProps {
className?: string;
}
/* -- Colour --------------------------------------------------------------------
* The component draws no colour of its own. `colors` is required, keyed by `key`, and a
* key missing from it is drawn in `--muted-foreground`: a neutral that belongs to no
* sequence, so a part nobody coloured reads as a part nobody coloured rather than as a
* member of the palette.
*
* That fallback is a report, not a repair. Falling back to `--chart-1` would claim a rank
* the part does not have. Falling back to nothing would be quieter still — a cut the
* track shows through, and a dot missing from the legend, which is exactly the kind of
* omission that survives a review. The cut is the wrong colour on purpose, where it can
* be seen. It also covers the caller whose parts arrive from a query and whose palette
* cannot name them in advance: an uncoloured part is a legitimate runtime state, not
* always a forgotten line.
*
* A palette is a statement about what the parts *mean* — which model is which — and that
* is not something a component can know. It sees this period's parts and nothing else, so
* a colour it invented would be a guess at an identity it cannot see, and the guess would
* be wrong in the quietest way there is: the same model changing hue between two months
* that are read side by side.
*
* `breakdownColors` is that guess, offered as a function rather than hidden as a default.
* It hands out the theme's five chart tokens in order — the sequence the theme already
* publishes for exactly this, so dark mode and a theme swap come free — and past five it
* walks them again with a lightness step mixed toward `--foreground`. Toward the ink,
* never toward the surface: a step toward the surface moves a part *into* whatever it is
* drawn on, which brightens it on a light theme and darkens it on a dark one, so the ramp
* would invert with the theme. Toward the ink every step moves away from the surface in
* both, which is the argument the heatmap's alpha ramp makes and the reason it makes it.
* Fifteen parts are distinguishable this way, past the point a stacked bar can be read.
*
* Hand it the catalogue, not the page: the stable list of every part that can appear, so
* that a part keeps its colour in a month it happens to be small in, or absent from.
* Handed the visible parts instead it is the same guess a default would have made — which
* is fine, as long as it is written at the call site where it can be read.
* --------------------------------------------------------------------------- */
const HUES = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
];
/** How much ink each pass past the first five mixes in. Three passes is fifteen parts,
* which is past the point where a stacked bar can be read at all. */
const STEPS = [0, 22, 44];
const colorAt = (position: number) => {
const hue = HUES[position % HUES.length];
const step = STEPS[Math.floor(position / HUES.length) % STEPS.length];
return step === 0
? hue
: `color-mix(in oklab, ${hue} ${100 - step}%, var(--foreground))`;
};
/** What a part with no entry in `colors` is drawn in. */
const UNASSIGNED = "var(--muted-foreground)";
/** A palette for a list of keys, in the order given: the theme's chart sequence, one
* token each, then the same hues walked again with a lightness step.
*
* Pass the keys of the catalogue — everything that can appear, not everything that did —
* so that a part's colour is a property of the part rather than of the month. */
export const breakdownColors = (keys: readonly string[]) =>
Object.fromEntries(keys.map((key, position) => [key, colorAt(position)]));
/* -- Formatting ----------------------------------------------------------------
* Both defaults are fixed to `en-US` rather than taken from the browser, for the reason
* the heatmap's `locale` is fixed: the server and the client have to render the same
* string, and a number that gains a separator on hydration is a mismatch. Both are props
* for the caller who wants their own language.
* --------------------------------------------------------------------------- */
const DEFAULT_NUMBER = new Intl.NumberFormat("en-US");
const DEFAULT_VALUE = (value: number) => DEFAULT_NUMBER.format(value);
const DEFAULT_SHARE = (share: number) => `${Math.round(share)}% of total`;
const BreakdownContext = createContext<BreakdownState | null>(null);
export const useBreakdown = () => {
const context = useContext(BreakdownContext);
if (!context) {
throw new Error("Breakdown parts must be used within a Breakdown.");
}
return context;
};
/**
* The root: it holds the parts, derives what they share, and draws nothing itself.
*
* A part with nothing in it is dropped rather than drawn at zero width — a cut you
* cannot see reads as a rendering fault, not as a part with no share. `total` is what
* the shares are read against and defaults to the sum, so passing it is only necessary
* when the parts on screen are a slice of something larger.
*/
export const Breakdown = ({
children,
colors,
formatShare,
formatValue,
items,
total,
}: BreakdownProps) => {
const state = useMemo<BreakdownState>(() => {
const shown = items.filter((item) => item.value > 0);
const whole = total ?? shown.reduce((sum, item) => sum + item.value, 0);
return {
formatShare: formatShare ?? DEFAULT_SHARE,
formatValue: formatValue ?? DEFAULT_VALUE,
items: shown.map((item) => ({
...item,
color: colors[item.key] ?? UNASSIGNED,
share: whole > 0 ? (item.value / whole) * 100 : 0,
})),
};
}, [colors, formatShare, formatValue, items, total]);
return (
<BreakdownContext.Provider value={state}>
{children}
</BreakdownContext.Provider>
);
};
/**
* The bar: one cut per part, as wide as its share.
*
* The widths are percentages and the gaps between the cuts are `gap-px`, which together
* would overrun the track by one pixel per cut — except that flex items shrink, and they
* shrink in proportion to their own width, so the cuts keep their ratios and the bar
* keeps its shape at any count. What the bar does not do is fill the track when the
* parts do not add up to `total`: the track showing through is the reading.
*/
export const BreakdownBar = ({ className, label }: BreakdownBarProps) => {
const { formatValue, items } = useBreakdown();
if (items.length === 0) {
return null;
}
const amounts = items
.map((item) => `${item.label} ${formatValue(item.value)}`)
.join(", ");
return (
<div
aria-label={label ? `${label}: ${amounts}` : amounts}
className={cn(
"flex h-2.5 gap-px overflow-hidden rounded-full bg-muted",
className
)}
data-slot="breakdown-bar"
role="img"
>
{items.map((item) => (
<span
className="h-full"
key={item.key}
style={{ background: item.color, width: `${item.share}%` }}
/>
))}
</div>
);
};
/**
* The legend: every part, its colour, its label and its amount.
*
* The share is not printed. The bar already carries the comparison, and a column of
* percentages beside it is the same fact said twice — so the exact number waits in the
* tooltip, where it costs nothing until it is asked for. It is in the accessible text
* either way, because a tooltip is a pointer's affordance and a screen reader has none.
*/
export const BreakdownLegend = ({ className }: BreakdownLegendProps) => {
const { formatShare, formatValue, items } = useBreakdown();
if (items.length === 0) {
return null;
}
return (
<div
className={cn(
"grid grid-cols-2 gap-x-8 gap-y-3 text-xs sm:grid-cols-3",
className
)}
data-slot="breakdown-legend"
>
{items.map((item) => (
<Tooltip key={item.key}>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ background: item.color }}
/>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{item.label}
</span>
<span className="shrink-0 text-muted-foreground tabular-nums">
{formatValue(item.value)}
</span>
<span className="sr-only">{formatShare(item.share)}</span>
</div>
</TooltipTrigger>
<TooltipContent>{formatShare(item.share)}</TooltipContent>
</Tooltip>
))}
</div>
);
};