Skip to content
KONIGI Patterns

AI Assistants / Limits and errors / Usage meter

‹ 3 of 3

Usage meter

People need to see their remaining allowance before they run out.

Updated September 12, 2026

Problem

People can run out of usage without warning if the product does not show their remaining allowance or when it resets.

Solution

Show the remaining allowance in a unit people understand, along with its reset time. Explain when some models or modes use substantially more of it.

Make usage easy to check, and draw attention to it before people reach the limit. Show relevant costs where people choose an expensive option.

At the limit, explain what happened and what remains available. Offer supported options, such as waiting for a reset or using a lower-cost mode. For shared allowances, show enough detail to explain team usage without exposing private work.

Use when

The product has usage limits people may reach, especially when requests consume different amounts.

Don’t use when

The limit is high enough that ordinary use never approaches it. Displaying a meter that reads full forever trains people to ignore the one indicator that would matter, and makes a generous product feel stingy.

Trade-offs

A prominent meter can discourage useful exploration. Hiding it makes limits feel sudden. Technical units may be precise but hard to translate into work, while estimates need to be labeled clearly.

Checklist

  • Is the remaining budget visible before it becomes urgent?
  • Is the reset time stated precisely?
  • Are expensive actions marked at the point of choice?
  • Is the unit something a viewer can turn into an expectation of work?
  • Does a stopped or regenerated response still count, and is that said?
  • On a shared plan, can the viewer see whose usage applied?
  • Is exhaustion a designed state with a path forward?
  • Is there a cheaper option that still works at the limit?
  • Does reasoning or tool use count separately from the visible answer?
  • Does the meter stay quiet during the sessions that never approach the ceiling?

Compare

Claude publishes its rate limits and reset behaviour in documentation, so the ceiling is a checkable property rather than something discovered in use. ChatGPT meters by model tier and degrades to a lesser model at the limit. The session stays alive, and the drop in quality is the signal that a limit was hit. Perplexity counts the expensive mode separately from ordinary questions, which makes the costly action legible as its own budget rather than a silent drain on a shared one. GitHub Copilot meters premium requests against a monthly allowance and shows the consumption per request type. Nothing else here is itemised so finely.

Model picker and Mode switch are where people choose options with different costs. Regenerate uses more allowance for another attempt.

Refusal describes other reasons a request cannot be completed. Context meter tracks the separate limit on how much information the model can use at once.

Usage meter anatomy A quota bar appearing at three-quarters consumed rather than at the wall, with a reset time, per-action cost marked at the point of choice, a named colleague's share on a team plan, and a cheaper model still available at the limit. Show the limit while there is still room to act you, 300 a colleague, 152 meter appears resets Tue 09:00 answer now 1 credit research 40 credits at the limit, the fast model still answers a stopped response still consumed what it wrote reasoning and tool calls count, and are invisible 1 2 3 4 1 A RESET, NOT A WALL Twenty minutes and next month are different products to the person waiting. 2 COST AT THE CHOICE More useful than accuracy in the meter, because it arrives while they can decide. 3 SOMETHING STILL WORKS Exhaustion is a designed state. A bare notice that the limit is reached ends it. 4 THE INVISIBLE SPEND Retries, reasoning and tool calls drain the same bucket without being seen. A meter shown always is ignored, and makes an unbounded product feel like a metered one.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A quota bar that appears at three-quarters rather than at the wall, with the reset time, the per-action cost at the point of choice, and a colleague's share on a team plan. Reasoning and tool calls count, and are invisible.

shadcn
npx shadcn@latest add button
Tokens
--card--card-foreground--muted--muted-foreground--border--chart-1--scale-seq-2--scale-seq-3
you, 300a colleague, 152resets Tue 09:00

at the limit, the fast model still answers

a stopped response still consumed what it wrote: 6 credits

UsageMeter.tsxRenders nothing under three-quarters. Reset, per-action cost and the fallback are required props; a stopped response is charged and says so.

import { Button } from "@/components/ui/button";

/** Who consumed what. On a team plan the colleague's share is the difference
 *  between a product problem and a people problem. */
export type Share = { who: string; used: number };

/** A thing the viewer can choose, with what it costs. The cost is required:
 *  marking it here, while they can still decide, is worth more than any
 *  accuracy in the bar. */
export type Action = { id: string; label: string; cost: number };

/** Shown from three-quarters consumed. Always-on is ignored, and makes an
 *  unbounded product feel metered. */
const APPEARS_AT = 0.75;

const SHARE_COLOR = ["bg-scale-seq-3", "bg-scale-seq-2", "bg-scale-seq-1"];

const resetLabel = (at: Date, now: Date) => {
  const days = (at.getTime() - now.getTime()) / 86_400_000;
  return days < 7
    ? at.toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC" })
    : at.toLocaleDateString("en-GB", { day: "numeric", month: "short", timeZone: "UTC" });
};

export function UsageMeter({ limit, unit = "credit", shares, resetsAt, now = new Date(), actions, onChoose, fallback, stoppedCost }: {
  limit: number;
  /** Something the viewer can turn into work. "credit", not "token". */
  unit?: string;
  /** The viewer first. */
  shares: Share[];
  /** A limit with no reset is a wall. */
  resetsAt: Date;
  /** The clock to count against. Pass one to render on a server. */
  now?: Date;
  actions: Action[];
  onChoose: (id: string) => void;
  /** What still works once the budget is gone. Exhaustion is a designed state. */
  fallback: string;
  /** What the last stopped response cost, if one was stopped. It counted. */
  stoppedCost?: number;
}) {
  const used = shares.reduce((n, s) => n + s.used, 0);
  if (used / limit < APPEARS_AT) return null;
  const left = Math.max(0, limit - used);
  const plural = (n: number) => `${n} ${unit}${n === 1 ? "" : "s"}`;

  return (
    <div className="text-xs text-muted-foreground">
      <div className="flex h-4 w-full overflow-hidden rounded-sm bg-muted" role="progressbar" aria-valuenow={used} aria-valuemax={limit}
        aria-label={`${used} of ${limit} ${unit}s used`}>
        {shares.map((s, i) => (
          <span key={s.who} className={SHARE_COLOR[i % SHARE_COLOR.length]} style={{ width: `${(s.used / limit) * 100}%` }} />
        ))}
      </div>
      <div className="mt-2 flex flex-wrap gap-x-4 tabular-nums">
        {shares.map((s) => <span key={s.who}>{s.who}, {s.used}</span>)}
        <span className="ml-auto">resets {resetLabel(resetsAt, now)}</span>
      </div>

      <div className="mt-4 flex flex-wrap gap-3 border-t pt-4">
        {actions.map((a) => {
          const dear = a.cost > left;
          return (
            <Button key={a.id} variant="outline" onClick={() => onChoose(a.id)} disabled={dear}
              className={`h-auto flex-col items-start gap-1 px-3 py-2 ${a.cost > 1 ? "border-chart-1 bg-chart-1/10 text-chart-1 hover:bg-chart-1/20 hover:text-chart-1" : "bg-muted"}`}>
              <span>{a.label}</span>
              <span className="text-[11px] font-normal tabular-nums">{plural(a.cost)}{dear && ", more than is left"}</span>
            </Button>
          );
        })}
        <p className="flex-1 basis-40 self-stretch rounded-md border bg-muted px-3 py-2 text-card-foreground">
          at the limit, {fallback} still answers
        </p>
      </div>

      {stoppedCost !== undefined && (
        <p className="mt-4">a stopped response still consumed what it wrote: {plural(stoppedCost)}</p>
      )}
    </div>
  );
}

demo.tsxHow it is called: a 600-credit team plan with 452 spent. Choosing an action spends its cost.

import { useState } from "react";
import { UsageMeter, type Share } from "./UsageMeter";

/**
 * A team plan of 600 credits with 452 gone, so the meter has appeared. The
 * viewer used 300, a colleague 152, and a response stopped a moment ago still
 * cost 6. Choosing an action spends its cost against the viewer's share.
 */
const NOW = new Date("2026-09-15T10:00:00Z");

export default function Demo() {
  const [shares, setShares] = useState<Share[]>([
    { who: "you", used: 300 },
    { who: "a colleague", used: 152 },
  ]);
  const spend = (cost: number) =>
    setShares(([you, ...rest]) => [{ ...you, used: you.used + cost }, ...rest]);

  return (
    <div className="rounded-lg border bg-card p-4">
      <UsageMeter
        limit={600}
        shares={shares}
        resetsAt={new Date("2026-09-22T09:00:00Z")}
        now={NOW}
        actions={[
          { id: "answer", label: "answer now", cost: 1 },
          { id: "research", label: "research", cost: 40 },
        ]}
        onChoose={(id) => spend(id === "research" ? 40 : 1)}
        fallback="the fast model"
        stoppedCost={6}
      />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No screenshots yet for this pattern. Browse Products to see other examples.