All patterns

Token Budget Meter

The fill climbs to the conversation's usage while its colour walks to amber, and the trim notice fades in at the threshold.

aiminimalsubtleautomatic · finite · intermediate · ~1.1s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

243 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import {
  animate,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Token Budget Meter
 *
 * How much of the context window this conversation has spent: the fill
 * grows to the current usage while its colour walks from calm to amber,
 * and a trim notice fades in only once the reader is close enough to the
 * limit for it to matter.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The track is mixed from the inherited text color, so the meter reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `used`, `limit`, `warnAt`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TokenBudgetMeterProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Tokens spent so far. */
  used?: number;
  /** Size of the context window. */
  limit?: number;
  /** Fraction of the window where the meter starts warning. */
  warnAt?: number;
  /** Row label. */
  label?: string;
  /** Line shown once the warn threshold is crossed. */
  warnNote?: string;
  /** Colour below the threshold. Semantic, so it stays literal. */
  accent?: string;
  /** Colour at and above the threshold. */
  warnColor?: string;
  /** Fires the first time the fill crosses `warnAt`. */
  onWarn?: () => void;
};

type VariantConfig = {
  /** How long the fill takes to reach the current usage. */
  fillSeconds: number;
  /** Beat before it starts, so the empty track is seen first. */
  delay: number;
};

// Deliberately a tween, not a spring: a budget meter that overshoots its
// value — even for 200ms — shows the reader a number that is not true.
// The curve decelerates hard so the last few percent, the ones that
// decide whether the warning appears, land slowly enough to read.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick and quiet. For a meter tucked into a composer toolbar.
  subtle: { fillSeconds: 0.7, delay: 0.05 },
  // The all-purpose setting.
  default: { fillSeconds: 1.05, delay: 0.1 },
  // A longer climb for a usage panel where the meter is the subject.
  playful: { fillSeconds: 1.4, delay: 0.14 },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` gives a track and a threshold notch that are correctly
 *  toned in either theme. The fill colours are state, so they stay
 *  literal. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function TokenBudgetMeter({
  variant = "default",
  used = 104000,
  limit = 128000,
  warnAt = 0.8,
  label = "Context window",
  warnNote = "Older messages will be trimmed soon",
  accent = "#7C7CF0",
  warnColor = "#E0A23C",
  onWarn,
}: TokenBudgetMeterProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const safeLimit = Math.max(1, limit);
  const target = Math.min(1, Math.max(0, used / safeLimit));
  // Kept strictly inside the track so the colour stops below stay in
  // ascending order whatever threshold a caller passes in.
  const threshold = Math.min(0.98, Math.max(0.05, warnAt));

  // One motion value drives the fill, the digits and the colour, so the
  // three can never disagree mid-climb.
  const progress = useMotionValue(0);
  const digits = useTransform(progress, (value) =>
    Math.round(value * safeLimit).toLocaleString("en-US")
  );
  const percent = useTransform(progress, (value) => `${Math.round(value * 100)}%`);
  const fill = useTransform(
    progress,
    [0, threshold * 0.85, threshold, 1],
    [accent, accent, warnColor, "#E5484D"]
  );

  const [warned, setWarned] = useState(false);
  const onWarnRef = useRef(onWarn);
  useEffect(() => {
    onWarnRef.current = onWarn;
  }, [onWarn]);

  // The notice is tied to the fill, not to a timer: it appears at the
  // moment the bar actually reaches the threshold, which is the only
  // moment it means anything.
  useMotionValueEvent(progress, "change", (value) => {
    if (value >= threshold) setWarned(true);
  });

  useEffect(() => {
    if (warned) onWarnRef.current?.();
  }, [warned]);

  useEffect(() => {
    // Reduced motion: the meter is simply at its value. A usage figure is
    // information, and the climb was only ever the presentation of it.
    if (reduceMotion) {
      progress.set(target);
      return;
    }
    progress.set(0);
    const controls = animate(progress, target, {
      duration: cfg.fillSeconds,
      delay: cfg.delay,
      ease: [0.22, 1, 0.36, 1],
    });
    return () => controls.stop();
  }, [progress, target, reduceMotion, cfg.fillSeconds, cfg.delay]);

  return (
    <div
      style={{
        width: 292,
        display: "flex",
        flexDirection: "column",
        gap: 8,
        fontSize: 12,
      }}
    >
      <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
        <span style={{ fontWeight: 600, opacity: 0.72 }}>{label}</span>
        <span
          style={{
            marginLeft: "auto",
            opacity: 0.55,
            fontVariantNumeric: "tabular-nums",
          }}
        >
          {/* Tabular figures: the count changes every frame and the slash
              must not shuffle sideways with it. */}
          <motion.span>{digits}</motion.span>
          {" / "}
          {safeLimit.toLocaleString("en-US")}
        </span>
      </div>

      <div
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={safeLimit}
        aria-valuenow={Math.round(target * safeLimit)}
        aria-label={label}
        style={{
          position: "relative",
          height: 6,
          borderRadius: 999,
          background: tone(11),
          overflow: "hidden",
        }}
      >
        {/* scaleX, not width: the fill is a transform, so a meter that
            updates on every message never triggers a layout pass. */}
        <motion.div
          style={{
            height: "100%",
            borderRadius: 999,
            transformOrigin: "left center",
            scaleX: progress,
            backgroundColor: fill,
          }}
        />
        <span
          aria-hidden
          style={{
            position: "absolute",
            top: 0,
            bottom: 0,
            left: `${threshold * 100}%`,
            width: 1.5,
            background: tone(30),
          }}
        />
      </div>

      <div style={{ display: "flex", alignItems: "center", gap: 8, minHeight: 16 }}>
        <motion.span
          style={{ opacity: 0.5, fontVariantNumeric: "tabular-nums" }}
        >
          {percent}
        </motion.span>
        {/* The notice only ever fades — a warning that slides or pops
            escalates a state that is still perfectly recoverable. */}
        <motion.span
          initial={false}
          animate={{ opacity: warned ? 1 : 0 }}
          transition={{ duration: reduceMotion ? 0 : 0.3, ease: "easeOut" }}
          style={{
            marginLeft: "auto",
            display: "inline-flex",
            alignItems: "center",
            gap: 5,
            color: warnColor,
            fontSize: 11,
            fontWeight: 550,
            whiteSpace: "nowrap",
          }}
        >
          <svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
            <circle cx="6" cy="6" r="4.9" stroke="currentColor" strokeWidth="1.3" />
            <path
              d="M6 3.4v3.1"
              stroke="currentColor"
              strokeWidth="1.4"
              strokeLinecap="round"
            />
            <circle cx="6" cy="8.5" r="0.75" fill="currentColor" />
          </svg>
          {warnNote}
        </motion.span>
      </div>
    </div>
  );
}

About this pattern

A context window is invisible until it runs out, and then it takes the earlier half of the conversation with it. This puts the budget on screen: one motion value drives the fill, the running count and the colour together, so the bar, the number and the tint can never disagree mid-climb. It is a tween rather than a spring on purpose — a meter that overshoots its value, even for a fifth of a second, shows a figure that is not true. The trim notice is tied to the fill crossing the threshold rather than to a timer, so it appears at the exact moment it starts to mean something.

Context window usageToken or credit budgetQuota nearing its limitLong conversation warning

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    A running token count against the selected model's window, shown beside the composer.

Related patterns