nextui.dev
Home
Docs
Saved
Pricing
Components37
  • Forms & Inputs7
  • Cards13
  • Buttons, Badges & Avatars8
  • Feedback & Display6
  • Effects3
Agents46
  • Input & Context9
  • Thinking & Status6
  • Tools & Execution7
  • Human-in-the-Loop & Safety7
  • Multi-Agent & Workflows4
  • Output & Artifacts8
  • Voice & Memory5
Blocks20
  • Agent chatNew
  • Pill heroNew
  • Grid hero
  • Moodboard generatorNew
  • Waitlist heroNew
  • Intro bannerNew
  • Creative home heroNew
  • Chat heroNew
  • App shelfNew
  • Usage card
  • Design resultsNew
  • Pricing tiers
  • Dashboard home
  • The shiftNew
  • Launch perksNew
  • Testimonials
  • Quote
  • Team
  • Leads tableNew
  • Sign in
Pages16
  • ASCII workspace$49
  • Research agent$49
  • Support agent$49
  • Coding agent$49
  • CRM overviewNew
  • Marketing dashboardNew
  • Developer portfolio$49
  • Particle landing$49
  • Particle story$49
  • Statement converter$49
  • AI photo studio$49
  • Group card launch$49
  • Month-end storyNew
  • Task HubNew
  • Global MediaNew
  • Hexora ecosystem$49
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
UpgradeDark modeLight modeFeedbackGitHub
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextui.dev

© 2026 nextui.dev • All rights reserved.

UpgradeDark modeLight modeFeedbackGitHub
ComponentsAI & AgentsCost tracker
Copy promptCopy prompt

Customize

Throughput in tokens per second

92tok/s

Prompt cache hit percentage

88%

Start with the per-step breakdown open

Loop a scripted token and cost accumulation demo

JSX · copied with Copy prompt

<CostTracker autoPlay />

Cost trackerNew

Real-time token, throughput, prompt-cache hit rate and USD cost telemetry for a multi-step agent run, opening into a per-step model breakdown.

Installation

Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.

$pnpm dlx shadcn@latest add @nextui/cost-tracker
1{
2 "registries": {
3 "@nextui": {
4 "url": "https://nextui.dev/r/{name}.json",
5 "headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }
6 }
7 }
8}

Usage

1"use client";
2
3import { CostTracker } from "@/components/nextui/cost-tracker";
4
5export function RunTelemetry() {
6 return <CostTracker tokensPerSec={94} cacheHitPct={86} />;
7}

API reference

Edit props live in the preview above, or see all 7 props
PropTypeDefaultDescription
stepsCostStep[]–Per-step model, input/output tokens, USD cost and prompt cache hit flag
tokensPerSecnumber92Throughput in tokens per second
cacheHitPctnumber88Prompt cache hit percentage
defaultOpenbooleantrueStart with the per-step breakdown open
ttftMsnumber"280"Time to first token in ms
autoPlaybooleanfalseLoop a scripted token and cost accumulation demo
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/cost-tracker.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { ChevronDown, Coins } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { cn } from "@/lib/utils";

export type CostStep = {
  id: string;
  label: string;
  model: string;
  inputTokens: number;
  outputTokens: number;
  costUsd: number;
  cached?: boolean;
  /** Share of input tokens served from the prompt cache, 0–100. */
  cachePct?: number;
  latencyMs?: number;
};

export type CostTrackerProps = {
  steps?: CostStep[];
  tokensPerSec?: number;
  /** Time to first token, in ms. */
  ttftMs?: number;
  cacheHitPct?: number;
  defaultOpen?: boolean;
  /** Loop a scripted run where cost, speed and steps accrue live. */
  autoPlay?: boolean;
  className?: string;
};

const DEFAULT_STEPS: CostStep[] = [
  { id: "s1", label: "Planner", model: "kite-large", inputTokens: 14200, outputTokens: 640, costUsd: 0.009, cachePct: 92, latencyMs: 1100 },
  { id: "s2", label: "Code edit · sub-agent", model: "kite-large", inputTokens: 18400, outputTokens: 1920, costUsd: 0.021, cachePct: 88, latencyMs: 3400 },
  { id: "s3", label: "Test runner", model: "kite-mini", inputTokens: 6100, outputTokens: 310, costUsd: 0.003, cachePct: 61, latencyMs: 900 },
  { id: "s4", label: "Reviewer", model: "kite-mini", inputTokens: 9800, outputTokens: 520, costUsd: 0.005, cachePct: 94, latencyMs: 1200 },
];

const SHADES = ["bg-foreground", "bg-foreground/60", "bg-foreground/35", "bg-foreground/15"];
const SPAN: [number, number][] = [
  [300, 1500],
  [1500, 4200],
  [4200, 5100],
  [5100, 6400],
];
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 9400;

function useDemoClock(enabled: boolean, loop: number) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible, setVisible] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  useEffect(() => {
    const el = ref.current;
    if (!el || !enabled) return;
    const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, [enabled]);
  useEffect(() => {
    if (!enabled || !visible) return;
    const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
    return () => clearInterval(id);
  }, [enabled, visible]);
  return { ref, t: elapsed % loop };
}

/** Tokens per second at demo time t: smooth but lively, settling on `base`. */
function speedAt(t: number, base: number) {
  if (t < 300 || t > 6400) return base;
  return Math.round(base + 9 * Math.sin(t / 260) + 5 * Math.sin(t / 97));
}

function AnimatedNumber({ value, format }: { value: number; format: (v: number) => string }) {
  const spring = useSpring(value, { stiffness: 120, damping: 22 });
  useEffect(() => {
    spring.set(value);
  }, [value, spring]);
  const text = useTransform(spring, format);
  return <motion.span>{text}</motion.span>;
}

/** Live FinOps readout for an agent run: total cost, tok/s with sparkline, TTFT, prompt-cache hit rate, and a per-step / sub-agent breakdown. */
export function CostTracker({
  steps = DEFAULT_STEPS,
  tokensPerSec = 92,
  ttftMs = 280,
  cacheHitPct = 88,
  defaultOpen = true,
  autoPlay = false,
  className,
}: CostTrackerProps) {
  const reduce = useReducedMotion() ?? false;
  const [touched, setTouched] = useState(false);
  const [openState, setOpen] = useState(defaultOpen);
  const demo = autoPlay && !reduce && !touched;
  const { ref, t } = useDemoClock(demo, LOOP);

  const progress = steps.map((_, i) => {
    if (!demo) return 1;
    const [a, b] = SPAN[i] ?? [0, 0];
    return Math.max(0, Math.min(1, (t - a) / (b - a)));
  });
  const live = demo && t >= 300 && t < 6400;
  const cost = steps.reduce((n, s, i) => n + s.costUsd * progress[i], 0);
  const tokens = steps.reduce((n, s, i) => n + (s.inputTokens + s.outputTokens) * progress[i], 0);
  const speed = demo ? speedAt(t, tokensPerSec) : tokensPerSec;
  const history = Array.from({ length: 24 }, (_, k) => (demo ? speedAt(t - (23 - k) * 260, tokensPerSec) : speedAt(1000 + k * 260, tokensPerSec)));
  const ttftShown = demo && t < 600 ? 0 : ttftMs;
  const cache = demo ? Math.round(cacheHitPct * Math.min(1, t / 1500)) : cacheHitPct;
  const open = openState;

  const min = Math.min(...history) - 4;
  const max = Math.max(...history) + 4;
  const path = history.map((v, k) => `${k === 0 ? "M" : "L"}${(k / 23) * 100},${28 - ((v - min) / (max - min)) * 26}`).join(" ");

  return (
    <div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
      <div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
        {/* Total */}
        <div className="flex items-center gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 pt-3.5 pb-3">
          <span className="grid size-9 shrink-0 place-items-center rounded-xl border bg-background shadow-xs">
            <Coins className="size-4" aria-hidden />
          </span>
          <div className="min-w-0 flex-1">
            <p className="text-[11px] text-muted-foreground">Run cost</p>
            <p className="font-mono text-xl leading-tight font-semibold tracking-tight tabular-nums">
              <AnimatedNumber value={cost} format={(v) => `$${v.toFixed(3)}`} />
            </p>
          </div>
          <div className="text-right">
            <span
              className={cn(
                "inline-flex h-5 items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium",
                live ? "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300" : "text-muted-foreground",
              )}
            >
              <span className={cn("size-1.5 rounded-full", live ? "bg-sky-500" : "bg-muted-foreground/50", live && !reduce && "animate-pulse")} />
              {live ? "Live" : "Done"}
            </span>
            <p className="mt-1 font-mono text-[10px] text-muted-foreground tabular-nums">
              <AnimatedNumber value={tokens} format={(v) => `${(v / 1000).toFixed(1)}k tok`} />
            </p>
          </div>
        </div>

        {/* Stats */}
        <div className="grid grid-cols-3 gap-2 px-3">
          <Stat label="Speed">
            <div className="flex items-end justify-between gap-1">
              <span className="font-mono text-sm font-semibold tabular-nums">
                {speed}
                <span className="ml-0.5 text-[10px] font-normal text-muted-foreground">tok/s</span>
              </span>
            </div>
            <svg viewBox="0 0 100 30" preserveAspectRatio="none" className="mt-1 h-5 w-full overflow-visible" aria-hidden>
              <path d={`${path} L100,30 L0,30 Z`} className="fill-foreground/[0.07]" />
              <path d={path} fill="none" className="stroke-foreground" strokeWidth={1.5} vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
            </svg>
          </Stat>
          <Stat label="TTFT">
            <span className="font-mono text-sm font-semibold tabular-nums">
              {ttftShown ? <AnimatedNumber value={ttftShown} format={(v) => `${Math.round(v)}`} /> : "—"}
              <span className="ml-0.5 text-[10px] font-normal text-muted-foreground">ms</span>
            </span>
            <span className="mt-2.5 block h-1.5 overflow-hidden rounded-full bg-muted" title="Against a 500ms target">
              <motion.span
                className={cn("block h-full rounded-full", ttftMs <= 500 ? "bg-emerald-500" : "bg-amber-500")}
                initial={false}
                animate={{ width: `${Math.min(100, (ttftShown / 500) * 100)}%` }}
                transition={{ duration: 0.6, ease: EASE }}
              />
            </span>
          </Stat>
          <Stat label="Prompt cache">
            <div className="flex items-center gap-2">
              <svg viewBox="0 0 36 36" className="size-8 shrink-0 -rotate-90" aria-hidden>
                <circle cx="18" cy="18" r="14" fill="none" className="stroke-muted" strokeWidth="4" />
                <motion.circle
                  cx="18"
                  cy="18"
                  r="14"
                  fill="none"
                  className="stroke-emerald-500"
                  strokeWidth="4"
                  strokeLinecap="round"
                  initial={false}
                  animate={{ pathLength: cache / 100 }}
                  transition={{ duration: 0.5, ease: EASE }}
                />
              </svg>
              <span className="font-mono text-sm leading-tight font-semibold tabular-nums">
                {cache}%<span className="block text-[10px] font-normal text-muted-foreground">cached</span>
              </span>
            </div>
          </Stat>
        </div>

        {/* Split bar */}
        <div className="px-4 pt-3">
          <div className="flex h-2 gap-px overflow-hidden rounded-full bg-muted" aria-hidden>
            {steps.map((s, i) => (
              <motion.span
                key={s.id}
                className={cn("h-full", SHADES[i % SHADES.length])}
                initial={false}
                animate={{ width: `${(s.costUsd * progress[i] * 100) / Math.max(0.0001, steps.reduce((n, x) => n + x.costUsd, 0))}%` }}
                transition={{ duration: 0.15, ease: "linear" }}
              />
            ))}
          </div>
        </div>

        {/* Breakdown */}
        <button
          type="button"
          aria-expanded={open}
          onClick={() => {
            setTouched(true);
            setOpen(!open);
          }}
          className="flex w-full cursor-pointer items-center justify-between px-4 pt-2.5 pb-2 text-[11px] text-muted-foreground outline-none hover:text-foreground focus-visible:text-foreground"
        >
          Per step · {steps.length} calls
          <ChevronDown className={cn("size-3.5 transition-transform duration-300", open && "rotate-180")} aria-hidden />
        </button>
        <AnimatePresence initial={false}>
          {open && (
            <motion.ul
              initial={reduce ? false : { height: 0, opacity: 0 }}
              animate={{ height: "auto", opacity: 1 }}
              exit={reduce ? undefined : { height: 0, opacity: 0 }}
              transition={{ duration: 0.35, ease: EASE }}
              className="overflow-hidden border-t pb-1.5"
            >
              {steps.map((s, i) => {
                const p = progress[i];
                const state = p >= 1 ? "done" : p > 0 ? "live" : "queued";
                return (
                  <li key={s.id} className={cn("flex items-center gap-2.5 px-4 py-1.5 text-xs transition-opacity duration-300", state === "queued" && "opacity-40")}>
                    <span className={cn("size-2 shrink-0 rounded-sm", SHADES[i % SHADES.length], state === "live" && !reduce && "animate-pulse")} />
                    <span className="min-w-0 flex-1">
                      <span className="block truncate font-medium">{s.label}</span>
                      <span className="block truncate font-mono text-[10px] text-muted-foreground">
                        {s.model} · {(s.inputTokens / 1000).toFixed(1)}k in / {s.outputTokens} out
                        {s.cachePct !== undefined && ` · ${s.cachePct}% cache`}
                        {s.latencyMs !== undefined && ` · ${(s.latencyMs / 1000).toFixed(1)}s`}
                      </span>
                    </span>
                    <span className="shrink-0 font-mono text-[11px] font-medium tabular-nums">${(s.costUsd * p).toFixed(3)}</span>
                  </li>
                );
              })}
            </motion.ul>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

function Stat({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <div className="min-w-0 rounded-xl border bg-background px-2.5 py-2">
      <p className="mb-1 truncate font-mono text-[9px] tracking-wider text-muted-foreground uppercase">{label}</p>
      {children}
    </div>
  );
}

export default function CostTrackerDemo() {
  return (
    <div className="flex h-[420px] w-full items-start justify-center pt-2">
      <CostTracker autoPlay />
    </div>
  );
}
Sign in