# Cost Estimate

> A pre-run estimate of what a task will cost, with a live balance meter, a low-balance warning, a top-up when the balance can't cover it and a breakdown.

- Category: indicators
- Job: Oversee — Show progress and reasoning while the AI works, and let people approve, pause or stop it.
- Slug: `cost-estimate`
- HTML: https://ai-patterns.app/patterns/indicators/cost-estimate
- JSON: https://ai-patterns.app/api/patterns/indicators/cost-estimate
- Seen in: [ElevenLabs](https://help.elevenlabs.io/hc/en-us/articles/30725662424849-How-much-does-GenFM-cost)

## Summary
A card next to the run action that shows what a task will cost before it starts — a figure or range in the product's unit, a rough duration, and a meter of how much of the user's balance it will use. It warns when a run would leave little behind and swaps the action for a top-up when the balance can't cover it.

## When to use
- Usage-priced actions whose cost varies with the input: long documents, batch jobs, video or image generation, agent runs.
- Expensive or slow tasks where an unexpected charge would break trust.
- Settings that change cost (depth, model, length), so users can see the trade-off as they adjust.

## When not to use
- Cheap, everyday actions (a chat reply). Showing a price on every message makes people anxious about using the product.
- Flat-rate plans with no per-action cost. Use Rate Limit when the constraint is a usage cap, not a price.
- When you can't estimate with reasonable accuracy. A wrong estimate is worse than none — show a range or explain what drives cost instead.

## Anatomy
- Estimate: "≈ 26 credits", or a range ("26–33 credits") when cost depends on what the run finds, plus an optional duration.
- Action: the run button, or a "Get more credits" button when the balance is short.
- Balance meter: the remaining balance with the run's share highlighted at the end of it.
- Status line: credits left after the run, a warning when that is low, or how many more credits are needed.
- Optional breakdown: "How is this estimated?" revealing the line items and a note on how billing works.

## Behavior
- The estimate updates live as the user changes inputs; numbers and the meter animate to their new values.
- Warning (amber) when the run would leave less than a set share of the balance; blocked (red) when the upper estimate exceeds the balance, which disables Run or replaces it with a top-up.
- With a range, use the upper bound for warnings, so a run is never started that might be cut off.
- Charge what the run actually uses, and show the final cost afterwards next to the estimate.

## Content guidelines
- Use the product's own unit (credits, tokens, minutes), not money, unless people pay per action in money.
- Round to whole units and prefix single figures with "≈".
- Say what happens at the edges plainly: "Needs 14 more credits than you have", "Leaves you with 8 credits".
- Keep the breakdown to two or three lines in the user's terms ("12 pages × 2 credits"), not internal token counts.

## Accessibility
- The meter is `role="meter"` with a text value ("33 of 120 credits").
- The status line is `aria-live="polite"`, so changes are announced as inputs change, and the Run button is described by it.
- Warning and blocked states use an icon and text as well as colour.
- Animations are skipped when the user prefers reduced motion.

## Related patterns
- **Rate Limit** — Use it instead for caps on usage over time rather than the price of a single action.
- **Model Picker** — Use it as well when models are priced differently, and show the estimate near it.
- **Tool Approval** — Use it instead when the action is risky rather than expensive, and needs a yes or no.
- **Human in the Loop Question** — Use it instead when the agent needs a choice from the person, not just confirmation of a cost.

## Implementation

Reference implementation (React, TypeScript, Tailwind v4, Motion, Radix). Keep every rule from the spec above; if the host project has its own design system, re-express the markup and motion in it rather than copying these classes.

npm dependencies: `lucide-react`, `motion`

Local imports to provide: `@/lib/utils` (`cn` is the usual `clsx` + `tailwind-merge` helper).

```tsx title="component.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { ChevronDown, TriangleAlert } from "lucide-react";

import { cn } from "@/lib/utils";

export interface CostLine {
  label: string;
  amount: number;
}

export interface CostEstimateProps {
  /** Expected cost. Pass `max` too when the cost can only be given as a range. */
  min: number;
  max?: number;
  /** What the user has left. */
  balance: number;
  /** Unit name in the plural ("credits", "tokens"). */
  unit?: string;
  /** Rough duration, already formatted ("~2 min"). */
  duration?: string;
  /** What the estimate is made of; shown behind "How is this estimated?". */
  breakdown?: CostLine[];
  /** Warn when what's left after the run would fall below this share of the balance. */
  warnBelow?: number;
  actionLabel?: string;
  onRun?: () => void;
  topUpLabel?: string;
  onTopUp?: () => void;
  className?: string;
}

type Level = "ok" | "warn" | "blocked";

export function CostEstimate({
  min,
  max,
  balance,
  unit = "credits",
  duration,
  breakdown,
  warnBelow = 0.2,
  actionLabel = "Run",
  onRun,
  topUpLabel = "Get more credits",
  onTopUp,
  className,
}: CostEstimateProps) {
  const [open, setOpen] = React.useState(false);
  const reduceMotion = useReducedMotion();
  const id = React.useId();

  const high = Math.max(min, max ?? min);
  const after = Math.max(0, balance - high);
  const level: Level = high > balance ? "blocked" : after < balance * warnBelow ? "warn" : "ok";
  const range = max !== undefined && max !== min;

  const total = Math.max(balance, 1);
  const usedPct = Math.min(100, (Math.min(high, balance) / total) * 100);

  return (
    <div className={cn("flex w-full flex-col gap-3 rounded-2xl border bg-card p-4", className)}>
      <div className="flex flex-wrap items-center gap-x-4 gap-y-3">
        <div className="min-w-0 flex-1">
          <p className="text-xs text-muted-foreground">Estimated cost</p>
          <p className="mt-0.5 text-lg font-semibold tabular-nums">
            {range ? (
              <>
                <AnimatedNumber value={min} instant={!!reduceMotion} />–<AnimatedNumber value={high} instant={!!reduceMotion} />
              </>
            ) : (
              <>
                ≈ <AnimatedNumber value={min} instant={!!reduceMotion} />
              </>
            )}{" "}
            <span className="text-sm font-normal text-muted-foreground">{unit}</span>
            {duration && <span className="text-sm font-normal text-muted-foreground"> · {duration}</span>}
          </p>
        </div>

        {level === "blocked" && onTopUp ? (
          <button
            type="button"
            onClick={onTopUp}
            className="shrink-0 rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90"
          >
            {topUpLabel}
          </button>
        ) : (
          <button
            type="button"
            onClick={onRun}
            disabled={level === "blocked"}
            aria-describedby={`${id}-status`}
            className="shrink-0 rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
          >
            {actionLabel}
          </button>
        )}
      </div>

      <div className="flex flex-col gap-1.5">
        <div
          role="meter"
          aria-label={`Share of balance this run uses`}
          aria-valuemin={0}
          aria-valuemax={balance}
          aria-valuenow={Math.min(high, balance)}
          aria-valuetext={`${high} of ${balance} ${unit}`}
          className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
        >
          <motion.div
            className="absolute inset-y-0 left-0 rounded-full bg-muted-foreground/30"
            initial={false}
            animate={{ width: `${100 - usedPct}%` }}
            transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 180, damping: 26 }}
          />
          <motion.div
            className={cn(
              "absolute inset-y-0 rounded-full",
              level === "ok" && "bg-foreground",
              level === "warn" && "bg-amber-500",
              level === "blocked" && "bg-destructive"
            )}
            initial={false}
            animate={{ left: `${100 - usedPct}%`, width: `${usedPct}%` }}
            transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 180, damping: 26 }}
          />
        </div>

        <div id={`${id}-status`} aria-live="polite" className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs">
          {level === "blocked" ? (
            <span className="flex items-center gap-1 font-medium text-destructive">
              <TriangleAlert className="size-3.5" aria-hidden />
              Needs {high - balance} more {unit} than you have
            </span>
          ) : level === "warn" ? (
            <span className="flex items-center gap-1 font-medium text-amber-700 dark:text-amber-400">
              <TriangleAlert className="size-3.5" aria-hidden />
              Leaves you with {after} {unit}
            </span>
          ) : (
            <span className="text-muted-foreground">
              {after} {unit} left after this run
            </span>
          )}
          <span className="text-muted-foreground tabular-nums">
            Balance {balance} {unit}
          </span>
        </div>
      </div>

      {breakdown && breakdown.length > 0 && (
        <div className="border-t pt-2">
          <button
            type="button"
            aria-expanded={open}
            aria-controls={`${id}-breakdown`}
            onClick={() => setOpen((o) => !o)}
            className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
          >
            How is this estimated?
            <ChevronDown className={cn("size-3.5 transition-transform", open && "rotate-180")} aria-hidden />
          </button>
          <AnimatePresence initial={false}>
            {open && (
              <motion.div
                id={`${id}-breakdown`}
                initial={{ height: 0, opacity: 0 }}
                animate={{ height: "auto", opacity: 1 }}
                exit={{ height: 0, opacity: 0 }}
                transition={{ duration: 0.18, ease: "easeOut" }}
                className="overflow-hidden"
              >
                <dl className="mt-2 flex flex-col gap-1 text-xs">
                  {breakdown.map((line) => (
                    <div key={line.label} className="flex justify-between gap-3">
                      <dt className="text-muted-foreground">{line.label}</dt>
                      <dd className="tabular-nums">
                        {line.amount} {unit}
                      </dd>
                    </div>
                  ))}
                </dl>
                <p className="mt-2 text-xs text-pretty text-muted-foreground">
                  You&apos;re only charged for what the run actually uses.
                </p>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      )}
    </div>
  );
}

function AnimatedNumber({ value, instant }: { value: number; instant: boolean }) {
  const spring = useSpring(value, { stiffness: 220, damping: 30 });
  const rounded = useTransform(spring, (v) => Math.round(v).toLocaleString());

  React.useEffect(() => {
    if (instant) spring.jump(value);
    else spring.set(value);
  }, [spring, value, instant]);

  return <motion.span>{rounded}</motion.span>;
}
```
