Skip to content
Scrim UI

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.

costtokensusagepricingmeter

Preview

claude-sonnet-518k in · 1.2k out$0.03
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

18420
15900
1240
780
0
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-meter

Follows 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-meter

Component 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
cost-meter.tsx
"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