free138 lines · framer-motion
Cost Meter
See the bill while it is still small. Live token and dollar counter for a run, with a budget bar, an 80% warning line and a spend sparkline.
- Use it for
- Run headers, admin dashboards, anywhere an agent spends money.
- Themes
- Light + dark
- License
- Commercial use (draft)
Live
Run cost
$0.087
4% of $2.00limit
- Input
- 18.4k tok
- Output
- 2.1k tok
tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion, useSpring, useTransform } from "framer-motion";
import { cn } from "@/lib/utils";
export interface CostMeterProps {
/** Budget in dollars for this run. */
budget?: number;
/** Prices per million tokens. */
inputPrice?: number;
outputPrice?: number;
/** Controlled usage. When omitted and autoplay is on, the meter simulates a run. */
inputTokens?: number;
outputTokens?: number;
autoplay?: boolean;
label?: string;
className?: string;
}
function Ticker({ value, format }: { value: number; format: (n: number) => string }) {
const reduce = useReducedMotion();
const spring = useSpring(value, { stiffness: 90, damping: 20 });
const text = useTransform(spring, (v) => format(v));
React.useEffect(() => {
if (reduce) spring.jump(value);
else spring.set(value);
}, [value, spring, reduce]);
return <motion.span className="tabular-nums">{text}</motion.span>;
}
const fmtTokens = (n: number) =>
n >= 1_000_000 ? `${(n / 1_000_000).toFixed(2)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n)}`;
export function CostMeter({
budget = 2,
inputPrice = 3,
outputPrice = 15,
inputTokens,
outputTokens,
autoplay = true,
label = "Run cost",
className,
}: CostMeterProps) {
const [sim, setSim] = React.useState({ inp: 18400, out: 2100, history: [0.06, 0.07, 0.09, 0.1, 0.12, 0.14, 0.15, 0.17] });
const controlled = inputTokens !== undefined && outputTokens !== undefined;
React.useEffect(() => {
if (controlled || !autoplay) return;
let seed = 7;
const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
const t = setInterval(() => {
setSim((s) => {
let inp = s.inp + Math.round(800 + rnd() * 6000);
let out = s.out + Math.round(120 + rnd() * 900);
const c = (inp * inputPrice + out * outputPrice) / 1e6;
if (c > budget * 1.08) {
inp = 18400;
out = 2100;
}
const cost = (inp * inputPrice + out * outputPrice) / 1e6;
return { inp, out, history: [...s.history.slice(-23), cost] };
});
}, 900);
return () => clearInterval(t);
}, [controlled, autoplay, inputPrice, outputPrice, budget]);
const inp = controlled ? inputTokens! : sim.inp;
const out = controlled ? outputTokens! : sim.out;
const cost = (inp * inputPrice + out * outputPrice) / 1e6;
const pct = Math.min(1, cost / budget);
const state = pct >= 1 ? "over" : pct >= 0.8 ? "warn" : "ok";
const history = controlled ? [cost] : sim.history;
const max = Math.max(...history, 0.0001) * 1.15;
const budgetY = budget <= max ? 30 - (budget / max) * 28 : null;
const points = history.map((v, i) => `${(i / Math.max(1, history.length - 1)) * 100},${30 - (v / max) * 28}`).join(" ");
return (
<section
aria-label={label}
className={cn("w-full max-w-sm rounded-xl border border-border bg-card p-4 text-card-foreground", className)}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</p>
<p className="mt-1 font-display text-3xl font-extrabold tracking-tight">
<Ticker value={cost} format={(n) => `$${n.toFixed(3)}`} />
</p>
</div>
<span
className={cn(
"rounded-full px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-wider",
state === "ok" && "bg-ok/10 text-ok",
state === "warn" && "bg-warn/15 text-warn",
state === "over" && "bg-destructive/10 text-destructive",
)}
>
{state === "ok" ? "On budget" : state === "warn" ? "Near limit" : "Over budget"}
</span>
</div>
<div className="mt-3" role="meter" aria-valuemin={0} aria-valuemax={budget} aria-valuenow={Number(cost.toFixed(3))} aria-label="Budget used">
<div className="relative h-2.5 overflow-hidden rounded-full bg-muted">
<motion.div
className={cn("absolute inset-y-0 left-0 rounded-full", state === "ok" ? "bg-primary" : state === "warn" ? "bg-warn" : "bg-destructive")}
animate={{ width: `${pct * 100}%` }}
transition={{ type: "spring", stiffness: 120, damping: 22 }}
/>
<span className="absolute inset-y-0 left-[80%] w-px bg-card" aria-hidden />
</div>
<div className="mt-1 flex justify-between font-mono text-[10px] text-muted-foreground">
<span>{Math.round(pct * 100)}% of ${budget.toFixed(2)}</span>
<span>limit</span>
</div>
</div>
<svg viewBox="0 0 100 32" preserveAspectRatio="none" className="mt-3 h-10 w-full" aria-hidden>
<polygon points={`0,32 ${points} 100,32`} className="fill-signal/10" />
<polyline points={points} fill="none" vectorEffect="non-scaling-stroke" strokeWidth="1.5" className="stroke-signal" />
{budgetY !== null && <line x1="0" x2="100" y1={budgetY} y2={budgetY} strokeDasharray="2 2" vectorEffect="non-scaling-stroke" className="stroke-muted-foreground/50" />}
</svg>
<dl className="mt-2 grid grid-cols-2 gap-2 font-mono text-[11px]">
<div className="rounded-lg bg-muted px-2.5 py-1.5">
<dt className="text-muted-foreground">Input</dt>
<dd className="font-semibold"><Ticker value={inp} format={fmtTokens} /> tok</dd>
</div>
<div className="rounded-lg bg-muted px-2.5 py-1.5">
<dt className="text-muted-foreground">Output</dt>
<dd className="font-semibold"><Ticker value={out} format={fmtTokens} /> tok</dd>
</div>
</dl>
</section>
);
}
export default CostMeter;
How it behaves
- →Cost = (input tokens × input price + output tokens × output price) / 1,000,000.
- →Numbers glide to new values with a spring ticker.
- →Status chip: On budget (<80%), Near limit (80–99%), Over budget (≥100%); the bar colour follows.
- →Sparkline of the last 24 samples (auto-scaled), with a dashed budget line once spend gets close.
- →Pass inputTokens and outputTokens to control it; otherwise autoplay simulates a run.
Accessibility
- ✓Budget bar is role=meter with min, max and current value.
- ✓Sparkline is decorative (aria-hidden).
- ✓Honours prefers-reduced-motion.