Browse all components
Cost Meter UI
Live spend per message and per conversation — cached input priced apart from fresh, reasoning tokens shown without double-counting, and a visible ~ when a provider did not report enough to be sure.
Preview
- Fresh input$3/M
- 2.5k · $0.0076
- Cached input$0.3/M
- 16k · $0.0048
- Output$15/M
- 1.2k · $0.02
- …of which reasoningbilled, not shown
- 780
Presets
Props
Agent promptClaude Code · Cursor · any agent
Add the Cost Meter UI component from the Scrim UI registry to this project and use it in the configuration described below.
Cost Meter UI — Live spend per message and per conversation — cached input priced apart from fresh, reasoning tokens shown without double-counting, and a visible ~ when a provider did not report enough to be sure.
## 1. Install
```bash
npx shadcn@latest add @scrimui/cost-meter
```
This writes a single file to `components/ui/cost-meter.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/cost-meter to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { CostMeter } from "@/components/ui/cost-meter";
// USD per million tokens. Kept at the call site — a rate table baked into a
// component is a rate table that goes stale in someone else's node_modules.
const PRICE = { input: 3, cachedInput: 0.3, output: 15 };
const usage = {
inputTokens: 18420,
cachedInputTokens: 15900,
outputTokens: 1240,
reasoningTokens: 780, // already inside outputTokens
};
<CostMeter
model="claude-sonnet-5"
price={PRICE}
usage={usage}
defaultOpen
/>
```
## 3. The configuration that matters
This is the component's "Per message" state — One turn, 86% of its input served from cache. Priced at the fresh rate it would read four times higher.
These props were set deliberately and should be preserved as written:
- `total` = false — Show the conversation total
Every other prop is at its default; leave those off the call site rather than writing the default value out.
Reference: https://scrimui.dev/components/cost-meterFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
Install
npx shadcn@latest add @scrimui/cost-meterComponent source
React + Tailwind component using shadcn/ui (button, card). The install command adds these dependencies automatically.
When copying source manually, install the required primitives first:
npx shadcn@latest add button card"use client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import * as React from "react";
/**
* Live spend, per message and per conversation.
*
* A number on screen that says "$0.14" is a claim, and a wrong claim about
* money is a support ticket. Almost every meter shipped in an AI product gets
* this wrong in one of four ways, and each one has an answer here:
*
* 1. **Cached input is not input.** The same prompt prefix is re-read on
* every turn, and providers charge a tenth or less for a cache read.
* `inputTokens` is the total billed for input, cache reads included, so
* multiplying it by the fresh rate over-bills a long conversation by an
* order of magnitude. The cached share is subtracted and priced apart.
* 2. **Reasoning tokens are billed and invisible.** They are already inside
* `outputTokens`; adding `reasoningTokens` on top double-counts. They are
* broken out here only so the meter can *show* what was paid for and
* never read — which is usually the line that explains the bill.
* 3. **Usage can be `undefined`.** Not every provider reports every field,
* and a stream that fails mid-turn reports none of them. `undefined` is
* not zero. Rendering "$0.00" for "we do not know" is the one outcome
* worse than rendering nothing, so an incomplete usage record produces
* `exact: false` and the meter shows a visible `~`.
* 4. **Sub-cent precision.** A meter that reads $0.00 for six turns and then
* jumps to $0.01 looks broken. Four decimals under a cent, two above it,
* where the extra digits stop being information.
*
* The prices live with the caller, not in here. A rate table baked into a
* component is a rate table that goes stale in someone else's node_modules.
*/
/* ------------------------------------------------------------------ */
/* Arithmetic */
/* ------------------------------------------------------------------ */
export type Usage = {
inputTokens?: number;
/** The share of `inputTokens` that was served from cache, not added to it. */
cachedInputTokens?: number;
outputTokens?: number;
/** Already counted inside `outputTokens`. Broken out to display, never to add. */
reasoningTokens?: number;
};
/** USD per million tokens, as every provider quotes them. */
export type ModelPrice = {
input: number;
cachedInput: number;
output: number;
};
export type Cost = {
usd: number;
/** False when a field the price depends on was missing. */
exact: boolean;
};
/** Sums two usage records, treating a missing field as missing, not as zero. */
export function addUsage(a: Usage, b: Usage): Usage {
const add = (x?: number, y?: number) =>
x === undefined && y === undefined ? undefined : (x ?? 0) + (y ?? 0);
return {
inputTokens: add(a.inputTokens, b.inputTokens),
cachedInputTokens: add(a.cachedInputTokens, b.cachedInputTokens),
outputTokens: add(a.outputTokens, b.outputTokens),
reasoningTokens: add(a.reasoningTokens, b.reasoningTokens),
};
}
export function costOf(usage: Usage, price: ModelPrice): Cost {
const cached = usage.cachedInputTokens ?? 0;
/* Clamped at zero because a provider that reports the two independently
can, briefly, disagree with itself mid-stream. */
const fresh = Math.max(0, (usage.inputTokens ?? 0) - cached);
const usd =
(fresh / 1_000_000) * price.input +
(cached / 1_000_000) * price.cachedInput +
((usage.outputTokens ?? 0) / 1_000_000) * price.output;
return { usd, exact: usage.inputTokens !== undefined && usage.outputTokens !== undefined };
}
export function formatCost({ usd, exact }: Cost): string {
const value = usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2);
return `${exact ? "" : "~"}$${value}`;
}
export function formatTokens(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
return `${(n / 1_000_000).toFixed(1)}M`;
}
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
function ChevronIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* CostMeter */
/* ------------------------------------------------------------------ */
export type CostMeterProps = {
/** Shown as attribution — the rates below are only true for one model. */
model: string;
price: ModelPrice;
/** This turn. */
usage: Usage;
/** The conversation so far, including this turn. Omit for a per-message meter. */
total?: Usage;
/** A cap to render progress against, in USD. */
budgetUsd?: number;
/**
* True while the turn is still generating. Output tokens are still climbing,
* so the figure is a running subtotal rather than a final one — and it is
* labelled as such instead of being animated as though it were settled.
*/
streaming?: boolean;
defaultOpen?: boolean;
className?: string;
};
export function CostMeter({
model,
price,
usage,
total,
budgetUsd,
streaming = false,
defaultOpen = false,
className = "",
}: CostMeterProps) {
const [open, setOpen] = React.useState(defaultOpen);
const turn = costOf(usage, price);
const running = total ? costOf(total, price) : undefined;
const headline = running ?? turn;
const cached = usage.cachedInputTokens ?? 0;
const fresh = Math.max(0, (usage.inputTokens ?? 0) - cached);
const reasoning = usage.reasoningTokens ?? 0;
/* Clamped at 1: a bar that overflows its track is a rendering bug, and a
bar pinned at full next to a number that keeps climbing is not. */
const spent = budgetUsd ? Math.min(1, headline.usd / budgetUsd) : 0;
const overBudget = budgetUsd !== undefined && headline.usd > budgetUsd;
return (
<Card className={`gap-0 py-0 rounded-xl border border-border bg-card ${className}`}>
<div className="flex items-center gap-3 px-3.5 py-2.5">
<span className="min-w-0 truncate font-mono text-xs text-muted-foreground">
{model}
</span>
<span className="ml-auto flex shrink-0 items-baseline gap-2.5">
<span className="tabular-nums text-xs text-muted-foreground">
{usage.inputTokens === undefined ? "—" : formatTokens(usage.inputTokens)} in ·{" "}
{usage.outputTokens === undefined ? "—" : formatTokens(usage.outputTokens)} out
</span>
<span
/* The figure changes while the reader is looking at it, so it is
announced politely rather than on every token. */
aria-live="polite"
className={`tabular-nums text-sm font-medium ${
overBudget ? "text-red-600 dark:text-red-400" : "text-foreground"
}`}
title={headline.exact ? undefined : "Approximate — the provider did not report every field."}
>
{formatCost(headline)}
</span>
</span>
<Button variant="ghost" size="icon-sm"
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={open ? "Hide the breakdown" : "Show the breakdown"}
className="min-h-6 min-w-6 -mr-1 inline-flex h-8 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-muted-foreground"
>
<ChevronIcon className={open ? "rotate-180" : ""} />
</Button>
</div>
{budgetUsd !== undefined && (
<div className="px-3.5 pb-2.5">
<div className="h-1 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-[width] duration-500 ${
overBudget ? "bg-red-500" : spent > 0.8 ? "bg-amber-500" : "bg-muted"
}`}
style={{ width: `${spent * 100}%` }}
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{overBudget
? `Over the ${formatCost({ usd: budgetUsd, exact: true })} budget for this conversation.`
: `of ${formatCost({ usd: budgetUsd, exact: true })} budgeted`}
</p>
</div>
)}
{open && (
<dl className="space-y-1.5 border-t border-border px-3.5 py-3 text-xs">
<Row
label="Fresh input"
value={usage.inputTokens === undefined ? "not reported" : `${formatTokens(fresh)} · ${money((fresh / 1e6) * price.input)}`}
hint={`$${price.input}/M`}
/>
{/* Shown even at zero, because "no cache hits on this turn" is
information — it is the difference between a conversation that
is getting cheaper and one that is not. */}
<Row
label="Cached input"
value={`${formatTokens(cached)} · ${money((cached / 1e6) * price.cachedInput)}`}
hint={`$${price.cachedInput}/M`}
/>
<Row
label="Output"
value={usage.outputTokens === undefined ? "not reported" : `${formatTokens(usage.outputTokens)} · ${money(((usage.outputTokens ?? 0) / 1e6) * price.output)}`}
hint={`$${price.output}/M`}
/>
{reasoning > 0 && (
<Row
label="…of which reasoning"
value={formatTokens(reasoning)}
hint="billed, not shown"
muted
/>
)}
{total && (
<div className="mt-2.5 flex items-baseline justify-between border-t border-border pt-2.5">
<dt className="text-muted-foreground">This turn</dt>
<dd className="tabular-nums font-medium text-foreground">
{formatCost(turn)}
</dd>
</div>
)}
{!headline.exact && (
<p className="pt-1 text-xs leading-4 text-amber-600 dark:text-amber-500">
The provider did not report every field for this turn, so the total is a lower bound.
That is what the ~ means.
</p>
)}
{streaming && (
<p className="pt-1 text-xs leading-4 text-muted-foreground">
Still generating — output tokens are a running subtotal.
</p>
)}
</dl>
)}
</Card>
);
}
function Row({ label, value, hint, muted = false }: { label: string; value: string; hint?: string; muted?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className={muted ? "text-muted-foreground" : "text-muted-foreground"}>
{label}
{hint && <span className="ml-1.5 text-muted-foreground">{hint}</span>}
</dt>
<dd className={`shrink-0 tabular-nums ${muted ? "text-muted-foreground" : "text-foreground"}`}>
{value}
</dd>
</div>
);
}
function money(usd: number): string {
return `$${usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)}`;
}
When to use it
- Pass `cachedInputTokens` as the share of `inputTokens` served from cache, not as a separate bucket added on top. Every provider reports it the first way and the second way over-bills a long conversation several times over.
- Leave a field `undefined` when the provider did not report it. That is the signal the component needs to show a ~ instead of a confident number.
- Pass `reasoningTokens` for display only. They are already inside `outputTokens`, and the reason to surface them is that they are the line which explains an unexpectedly large bill.
- Keep the rate table in your own code and pass it in. Prices change, and a component that ships its own is one that will be quietly wrong after the next provider announcement.
- Accumulate with `addUsage` rather than adding fields by hand — it keeps a missing field missing across a whole conversation instead of quietly turning it into a zero at the first sum.
- Show the per-conversation total once a conversation is long enough for the per-message figure to stop being the interesting one.
What breaks in production
- Pricing all input at the fresh rate. On an agent run or a long chat the same prefix is re-read every turn, and the meter reads roughly an order of magnitude high.
- Adding reasoning tokens to output tokens. They are already counted, and the double-count grows with exactly the kind of turn a user is most likely to check.
- Treating a missing field as zero. '$0.00' and 'we do not know' look identical on screen and mean opposite things to whoever reads the invoice later.
- Rounding to two decimals under a cent. Six turns at $0.00 followed by a jump to $0.01 reads as a broken meter, and the reader stops trusting the number at exactly the point it starts to matter.
- Animating the figure as it climbs mid-stream. It is a subtotal, and a number that counts up smoothly looks more settled than one that is still moving.
- Letting the budget bar overflow its track. A bar past 100% is a rendering bug; a bar pinned at full beside a number that keeps climbing is the actual situation.
Related Components
A standalone model picker — compare models in a dropdown with badges for speed, context length and what each supports.
A control for how hard the model should think — light, balanced or deep, with the tradeoff between speed and depth shown.
Switches for the tools a model may use — web search, code execution, file access — with unmistakable on and off states.
The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.