{"slug":"cost-estimate","category":"indicators","job":"oversee","added":"2026-11-07T22:47Z","title":"Cost Estimate","description":"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.","url":"https://ai-patterns.app/patterns/indicators/cost-estimate","markdown":"https://ai-patterns.app/patterns/indicators/cost-estimate.md","json":"https://ai-patterns.app/api/patterns/indicators/cost-estimate","seenIn":[{"team":"ElevenLabs","source":"https://help.elevenlabs.io/hc/en-us/articles/30725662424849-How-much-does-GenFM-cost"}],"spec":"# Cost Estimate\n\n## Summary\nA 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.\n\n## When to use\n- Usage-priced actions whose cost varies with the input: long documents, batch jobs, video or image generation, agent runs.\n- Expensive or slow tasks where an unexpected charge would break trust.\n- Settings that change cost (depth, model, length), so users can see the trade-off as they adjust.\n\n## When not to use\n- Cheap, everyday actions (a chat reply). Showing a price on every message makes people anxious about using the product.\n- Flat-rate plans with no per-action cost. Use Rate Limit when the constraint is a usage cap, not a price.\n- When you can't estimate with reasonable accuracy. A wrong estimate is worse than none — show a range or explain what drives cost instead.\n\n## Anatomy\n- Estimate: \"≈ 26 credits\", or a range (\"26–33 credits\") when cost depends on what the run finds, plus an optional duration.\n- Action: the run button, or a \"Get more credits\" button when the balance is short.\n- Balance meter: the remaining balance with the run's share highlighted at the end of it.\n- Status line: credits left after the run, a warning when that is low, or how many more credits are needed.\n- Optional breakdown: \"How is this estimated?\" revealing the line items and a note on how billing works.\n\n## Behavior\n- The estimate updates live as the user changes inputs; numbers and the meter animate to their new values.\n- 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.\n- With a range, use the upper bound for warnings, so a run is never started that might be cut off.\n- Charge what the run actually uses, and show the final cost afterwards next to the estimate.\n\n## Content guidelines\n- Use the product's own unit (credits, tokens, minutes), not money, unless people pay per action in money.\n- Round to whole units and prefix single figures with \"≈\".\n- Say what happens at the edges plainly: \"Needs 14 more credits than you have\", \"Leaves you with 8 credits\".\n- Keep the breakdown to two or three lines in the user's terms (\"12 pages × 2 credits\"), not internal token counts.\n\n## Accessibility\n- The meter is `role=\"meter\"` with a text value (\"33 of 120 credits\").\n- The status line is `aria-live=\"polite\"`, so changes are announced as inputs change, and the Run button is described by it.\n- Warning and blocked states use an icon and text as well as colour.\n- Animations are skipped when the user prefers reduced motion.\n\n## Related patterns\n- **Rate Limit** — Use it instead for caps on usage over time rather than the price of a single action.\n- **Model Picker** — Use it as well when models are priced differently, and show the estimate near it.\n- **Tool Approval** — Use it instead when the action is risky rather than expensive, and needs a yes or no.\n- **Human in the Loop Question** — Use it instead when the agent needs a choice from the person, not just confirmation of a cost.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { ChevronDown, TriangleAlert } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface CostLine {\n  label: string;\n  amount: number;\n}\n\nexport interface CostEstimateProps {\n  /** Expected cost. Pass `max` too when the cost can only be given as a range. */\n  min: number;\n  max?: number;\n  /** What the user has left. */\n  balance: number;\n  /** Unit name in the plural (\"credits\", \"tokens\"). */\n  unit?: string;\n  /** Rough duration, already formatted (\"~2 min\"). */\n  duration?: string;\n  /** What the estimate is made of; shown behind \"How is this estimated?\". */\n  breakdown?: CostLine[];\n  /** Warn when what's left after the run would fall below this share of the balance. */\n  warnBelow?: number;\n  actionLabel?: string;\n  onRun?: () => void;\n  topUpLabel?: string;\n  onTopUp?: () => void;\n  className?: string;\n}\n\ntype Level = \"ok\" | \"warn\" | \"blocked\";\n\nexport function CostEstimate({\n  min,\n  max,\n  balance,\n  unit = \"credits\",\n  duration,\n  breakdown,\n  warnBelow = 0.2,\n  actionLabel = \"Run\",\n  onRun,\n  topUpLabel = \"Get more credits\",\n  onTopUp,\n  className,\n}: CostEstimateProps) {\n  const [open, setOpen] = React.useState(false);\n  const reduceMotion = useReducedMotion();\n  const id = React.useId();\n\n  const high = Math.max(min, max ?? min);\n  const after = Math.max(0, balance - high);\n  const level: Level = high > balance ? \"blocked\" : after < balance * warnBelow ? \"warn\" : \"ok\";\n  const range = max !== undefined && max !== min;\n\n  const total = Math.max(balance, 1);\n  const usedPct = Math.min(100, (Math.min(high, balance) / total) * 100);\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-3 rounded-2xl border bg-card p-4\", className)}>\n      <div className=\"flex flex-wrap items-center gap-x-4 gap-y-3\">\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"text-xs text-muted-foreground\">Estimated cost</p>\n          <p className=\"mt-0.5 text-lg font-semibold tabular-nums\">\n            {range ? (\n              <>\n                <AnimatedNumber value={min} instant={!!reduceMotion} />–<AnimatedNumber value={high} instant={!!reduceMotion} />\n              </>\n            ) : (\n              <>\n                ≈ <AnimatedNumber value={min} instant={!!reduceMotion} />\n              </>\n            )}{\" \"}\n            <span className=\"text-sm font-normal text-muted-foreground\">{unit}</span>\n            {duration && <span className=\"text-sm font-normal text-muted-foreground\"> · {duration}</span>}\n          </p>\n        </div>\n\n        {level === \"blocked\" && onTopUp ? (\n          <button\n            type=\"button\"\n            onClick={onTopUp}\n            className=\"shrink-0 rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90\"\n          >\n            {topUpLabel}\n          </button>\n        ) : (\n          <button\n            type=\"button\"\n            onClick={onRun}\n            disabled={level === \"blocked\"}\n            aria-describedby={`${id}-status`}\n            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\"\n          >\n            {actionLabel}\n          </button>\n        )}\n      </div>\n\n      <div className=\"flex flex-col gap-1.5\">\n        <div\n          role=\"meter\"\n          aria-label={`Share of balance this run uses`}\n          aria-valuemin={0}\n          aria-valuemax={balance}\n          aria-valuenow={Math.min(high, balance)}\n          aria-valuetext={`${high} of ${balance} ${unit}`}\n          className=\"relative h-2 w-full overflow-hidden rounded-full bg-muted\"\n        >\n          <motion.div\n            className=\"absolute inset-y-0 left-0 rounded-full bg-muted-foreground/30\"\n            initial={false}\n            animate={{ width: `${100 - usedPct}%` }}\n            transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 180, damping: 26 }}\n          />\n          <motion.div\n            className={cn(\n              \"absolute inset-y-0 rounded-full\",\n              level === \"ok\" && \"bg-foreground\",\n              level === \"warn\" && \"bg-amber-500\",\n              level === \"blocked\" && \"bg-destructive\"\n            )}\n            initial={false}\n            animate={{ left: `${100 - usedPct}%`, width: `${usedPct}%` }}\n            transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 180, damping: 26 }}\n          />\n        </div>\n\n        <div id={`${id}-status`} aria-live=\"polite\" className=\"flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs\">\n          {level === \"blocked\" ? (\n            <span className=\"flex items-center gap-1 font-medium text-destructive\">\n              <TriangleAlert className=\"size-3.5\" aria-hidden />\n              Needs {high - balance} more {unit} than you have\n            </span>\n          ) : level === \"warn\" ? (\n            <span className=\"flex items-center gap-1 font-medium text-amber-700 dark:text-amber-400\">\n              <TriangleAlert className=\"size-3.5\" aria-hidden />\n              Leaves you with {after} {unit}\n            </span>\n          ) : (\n            <span className=\"text-muted-foreground\">\n              {after} {unit} left after this run\n            </span>\n          )}\n          <span className=\"text-muted-foreground tabular-nums\">\n            Balance {balance} {unit}\n          </span>\n        </div>\n      </div>\n\n      {breakdown && breakdown.length > 0 && (\n        <div className=\"border-t pt-2\">\n          <button\n            type=\"button\"\n            aria-expanded={open}\n            aria-controls={`${id}-breakdown`}\n            onClick={() => setOpen((o) => !o)}\n            className=\"flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground\"\n          >\n            How is this estimated?\n            <ChevronDown className={cn(\"size-3.5 transition-transform\", open && \"rotate-180\")} aria-hidden />\n          </button>\n          <AnimatePresence initial={false}>\n            {open && (\n              <motion.div\n                id={`${id}-breakdown`}\n                initial={{ height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={{ height: 0, opacity: 0 }}\n                transition={{ duration: 0.18, ease: \"easeOut\" }}\n                className=\"overflow-hidden\"\n              >\n                <dl className=\"mt-2 flex flex-col gap-1 text-xs\">\n                  {breakdown.map((line) => (\n                    <div key={line.label} className=\"flex justify-between gap-3\">\n                      <dt className=\"text-muted-foreground\">{line.label}</dt>\n                      <dd className=\"tabular-nums\">\n                        {line.amount} {unit}\n                      </dd>\n                    </div>\n                  ))}\n                </dl>\n                <p className=\"mt-2 text-xs text-pretty text-muted-foreground\">\n                  You&apos;re only charged for what the run actually uses.\n                </p>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction AnimatedNumber({ value, instant }: { value: number; instant: boolean }) {\n  const spring = useSpring(value, { stiffness: 220, damping: 30 });\n  const rounded = useTransform(spring, (v) => Math.round(v).toLocaleString());\n\n  React.useEffect(() => {\n    if (instant) spring.jump(value);\n    else spring.set(value);\n  }, [spring, value, instant]);\n\n  return <motion.span>{rounded}</motion.span>;\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}