Skip to content
loopkit
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

On budget
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.