Charts

Token Usage

A four-state LLM spend card: stacked bars split input, cached input, output and reasoning tokens per period, with the cache-hit share called out and a cost line derived in-component from per-million rates.

Preview in your theme

Loading previewโ€ฆ

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  TOKEN_CLASSES,
  TOKEN_CLASS_LABELS,
  type ChartTokenUsageClass,
  type ChartTokenUsageData,
  type ChartTokenUsagePeriod,
  type ChartTokenUsageRates,
} from "./chart-token-usage.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-token-usage.json

Prompt

Build a React + TypeScript + Tailwind "ChartTokenUsage" LLM-spend card in plain
SVG with zod. Not recharts, and not a generic stacked bar: the second axis is
not a supplied series โ€” it is tokens multiplied by per-million list prices,
computed in the component โ€” and the four stacked classes are a closed taxonomy
whose whole point is that they are priced differently.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    periods: { label: string; prompt: number; cachedPrompt: number;
               completion: number; reasoning?: number }[];
    rates: { prompt: number; cachedPrompt: number; completion: number;
             reasoning?: number };
    currency?: string }.
- The four classes are fixed and ordered: prompt, cachedPrompt, completion,
  reasoning โ€” bottom to top in the stack, palette slot 1..4. Export the tuple
  and a label map so the legend, the readouts and the table all read one source.
- prompt and cachedPrompt are DISJOINT halves of one input stream: cachedPrompt
  is the part a prefix cache served, so the cache-hit share is
  cachedPrompt / (prompt + cachedPrompt). Say so in the JSDoc โ€” a feed that
  sends the input total in prompt AND the hits in cachedPrompt double-counts
  every figure on the card.
- Counts are per period, never running totals. Rates are currency per 1,000,000
  tokens, the unit every vendor publishes.
- reasoning is optional per period; the class exists if ANY period supplied the
  field, decided by the schema and not by the values (a week that spent zero
  reasoning tokens still has a reasoning budget, and dropping the class would
  redraw the legend between refreshes). rates.reasoning falls back to
  rates.completion โ€” every frontier vendor bills thinking tokens as output.
- currency is an ISO 4217 code, restricted by regex so an unvalidated feed
  cannot make Intl.NumberFormat throw; default USD, and re-check it in the
  component anyway.
- refine: a ready card needs at least one period; period labels must be unique
  (a label is the bar's tick and its table row header).
- Props = z.infer plus height (default 190, clamped 140..420), onRetry,
  className and the div's native props (Omit "title"); forwardRef to the card.
- Export the maths beside the component so a test can print the numbers the
  picture is made of: buildTokenUsageModel(), apportionPercent(),
  tokenAxisStep().

Behavior
- DERIVE THE MONEY, NEVER ACCEPT IT. Cost per class per period is
  tokens * rate / 1e6, summed up. A cost series handed in alongside the tokens
  can silently disagree with them, and then the card is drawing two months.
- LEGEND IS THE FILTER IS THE LEDGER. One chip per class, a real button with
  aria-pressed: swatch, name, total tokens, its share of what is currently
  shown, its cost and its per-million rate. Pressing it removes the class from
  the stack, from the cost line and from both totals. Refuse to switch the last
  one off and say why in a role="status" line rather than no-opping silently.
  A switched-off chip is toggled, NOT disabled, so it stays fully operable and
  keeps AA contrast: mark the off state with a dashed border and a struck-through
  class name, never a blanket opacity that drags the muted readouts under 4.5:1.
- EXACTLY 100, IN EVERY FILTER STATE. Shares are apportioned by largest
  remainder (Hamilton): floor every share, then hand the leftover points to the
  largest fractional parts, ties broken by the larger raw value then by
  position. Rounding each share on its own is how four classes print 101 under
  one filter and 99 under the next. A class with nothing in it has no fraction
  to claim a point, so a real zero survives as a zero.
- ONE MARK, ONE VALUE. Each segment's top and bottom come from the running
  total in VALUE space, so a segment is always positioned by the same number
  that colours and labels it. No minimum-height floor: it would push every
  segment above it off its own value.
- TWO AXES, ONE GRID. Pick the token step as the smallest nice rung (1 / 2 /
  2.5 / 5 x 10^n) that fits the tallest visible bar into about 4 intervals, then
  choose the cost step to fit the SAME interval count, so a money label always
  sits level with a token label instead of implying a second, invisible grid.
- CALL OUT THE CACHE. Two tiles: the cache-hit share of the input stream with
  the money the spread saved (cachedPrompt * (promptRate - cachedRate) / 1e6,
  floored at zero โ€” a cache read priced above a fresh one saves nothing), and
  the blended rate per million of whatever is on screen. Say in words that the
  cache figure is measured on the whole input stream and does not move with the
  filter. A zero saving has two causes and the caption must not confuse them:
  no rate spread at all, versus a real spread nothing has hit yet (every period
  pre-caching). Only the first may be worded as "no spread" โ€” the second says
  the spread exists and no cache reads have landed on it.
- NAME THE CLASS THAT COSTS MORE THAN IT WEIGHS. Apportion token shares and
  cost shares over the whole stream, take the class with the largest positive
  gap, and print both integers in one sentence. It is the insight a token count
  alone hides: reasoning is routinely a fifteenth of the tokens and a fifth of
  the bill.
- Four first-class branches of one card: loading (skeleton mirroring tiles,
  plot and chips, aria-hidden, plus an sr-only role=status line), empty (worded
  so it cannot be mistaken for a failed fetch, and it explains that rates alone
  cannot draw a chart), error (Try again only when onRetry was passed), ready.
  status "ready" with no periods, or with every count zero, renders the empty
  branch and says which of the two happened.
- CLEANUP: one ResizeObserver measuring the plot column so SVG user units are
  CSS pixels, disconnected on unmount and whenever the node changes. No timers,
  no rAF, no window listeners.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel, border gridlines
  and tiles, muted skeleton, muted-foreground axis text, text-destructive for
  the error headline, ring for focus. Classes take var(--chart-1..4) from one
  index formula shared by the bar fill, the legend swatch and the table order;
  the derived cost line takes var(--chart-5), because it is not one of the four.
  Never a hex, never a low-alpha wash that disappears on a near-black card.
- The cost line is stroked twice: a wider stroke-card halo underneath, then the
  chart token on top, so it stays readable wherever it crosses a bar in either
  theme. Segments carry a half-pixel stroke-card seam so neighbouring slabs
  separate without eroding a thin one.
- Tick thinning, not tick overlap: the widest period label sets a stride, and
  the stride is counted back from the newest period so the right-hand end of a
  time series always keeps its name. Money ticks take compact notation with a
  precision derived from the step, so a sub-unit step still prints cents.
- ACCESSIBILITY: role="img" on the svg with a full sentence label โ€” honest,
  because nothing inside the frame is a tab stop; the filter lives in real
  buttons and every number is repeated in an sr-only table (sr-only on the
  WRAPPER div, never on the table itself: CSS width is only a lower bound for a
  table box). The svg label describes the FILTERED plot; the table is the
  unfiltered ledger, so give it its own caption carrying the all-class totals
  and naming how many classes are hidden from the plot but still listed below.
  Captioning the table with the filtered sentence is how a screen reader hears
  two contradictory totals for one card. Chips are focus-visible ringed and
  carry a spoken sentence.
- Motion: the only animation is the skeleton pulse and the chip hover; both
  carry motion-reduce:animate-none / motion-reduce:transition-none.

Customization levers
- Period grain: the contract takes labels, not dates, so the same card serves
  days, weeks, deploys or eval runs โ€” change the label and the axis follows.
- Density: height is the plot box; the two tiles and the footnote paragraph are
  the first things to drop for a dashboard tile, the chips the last.
- Class set: drop reasoning by omitting the field everywhere; add a fifth class
  (cache WRITES bill above the base rate on some vendors) by extending the
  tuple, the label map and the palette formula together โ€” they are one edit.
- Money: pass currency for locale formatting, or swap the blended-rate tile for
  cost per request or per task by dividing by a count you already have.
- Interaction: for a static report card, render the chips as plain legend rows
  and delete the hidden set; for a shared filter across a page of charts, lift
  hidden into a controlled prop and emit onVisibleChange.
- Axis pairing: TARGET_INTERVALS trades gridline density against how much air
  the money axis carries; raising it tightens the cost scale at the price of a
  busier grid.
- Counterfactual: the saving figure already computes what the cached tokens
  would have cost at the full input rate โ€” draw it as a second dashed line if a
  before-and-after story is the point of the page.

Concepts

  • Tokens and money are two different pictures โ€” in the shipped example the bars barely move from night to night while the cost line falls by almost half, because prefix caching went live mid-week and pushed most of the input stream (78% of it by the last night) onto a rate exactly a tenth of the old one. A usage chart that plots only tokens shows a flat week and explains nothing about the invoice; that gap is why this card carries a second axis at all.
  • The second axis is derived, not delivered โ€” cost is tokens ร— rate รท 1,000,000, computed from the same counts the bars are drawn from. Accepting a cost series alongside the tokens lets the two disagree, and a card that disagrees with itself is worse than no card.
  • A closed taxonomy, because the prices differ โ€” input, cached input, output and reasoning are not interchangeable "series": a cache read costs about a tenth of a fresh read, and a reasoning token costs what an output token costs even though nobody ever reads it. Collapsing them into "in and out" destroys the only figure worth looking at.
  • Legend, ledger and filter are one control โ€” the swatch that names a class carries its tokens, its share, its rate and its cost, and pressing it takes the class out of the stack, the line and the totals. Switching the last one off is refused out loud rather than silently ignored.
  • Largest remainder, so every filter state totals 100 โ€” shares take their floor first and the leftover points go to the largest fractional parts. Rounding each share on its own is how a four-way split prints 101 in one filter state and 99 in the next, which on a billing card reads as a data error, because it would be one.
  • The class that costs more than it weighs โ€” the card apportions token shares and cost shares over the whole stream and names the widest positive gap in a sentence. Reasoning tokens are the usual answer, and they are the line item nobody budgets for: invisible to the user, billed as output, and absent from any chart that only counts what the model returned.

On This Page