AI Patterns

Context UsageNew

A context-window meter: tokens used against the limit, a segmented bar of what fills it, a legend to inspect each source and a Compact action to free space.

JobOversee
Seen in

Context

175.6Kof 200K

"use client";

import * as React from "react";
import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";

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

export interface ContextSegment {
  /** Stable id, also used as the React key. */
  id: string;
  label: string;
  tokens: number;
  /** Tailwind background class for the segment and its legend dot. */
  color: string;
}

export interface ContextUsageProps {
  /** What fills the context window, in the order it's drawn. */
  segments: ContextSegment[];
  /** Size of the context window, in tokens. */
  limit: number;
  /** Share of the window (0–1) at which the meter warns that it's nearly full. */
  warnAt?: number;
  title?: string;
  compactLabel?: string;
  /** Summarise older content to free space. Return a promise to show a pending state. */
  onCompact?: () => void | Promise<void>;
  className?: string;
}

export function formatTokens(n: number) {
  if (n >= 1_000_000) return `${trim(n / 1_000_000)}M`;
  if (n >= 1_000) return `${trim(n / 1_000)}K`;
  return `${Math.round(n)}`;
}

function trim(v: number) {
  return v.toFixed(1).replace(/\.0$/, "");
}

const spring = { type: "spring", stiffness: 200, damping: 28 } as const;

export function ContextUsage({
  segments,
  limit,
  warnAt = 0.9,
  title = "Context",
  compactLabel = "Compact",
  onCompact,
  className,
}: ContextUsageProps) {
  const reduceMotion = !!useReducedMotion();
  const id = React.useId();
  const [active, setActive] = React.useState<string | null>(null);
  const [compacting, setCompacting] = React.useState(false);

  const used = segments.reduce((sum, s) => sum + s.tokens, 0);
  const share = Math.min(1, used / Math.max(limit, 1));
  const free = Math.max(0, limit - used);
  const warn = share >= warnAt;
  const focused = segments.find((s) => s.id === active);
  const transition = reduceMotion ? { duration: 0 } : spring;

  async function compact() {
    if (!onCompact || compacting) return;
    setCompacting(true);
    try {
      await onCompact();
    } finally {
      setCompacting(false);
    }
  }

  return (
    <div className={cn("flex w-full flex-col gap-4 rounded-3xl border bg-card p-5", className)}>
      <div className="flex items-center justify-between gap-3">
        <h3 className="text-base font-medium">{title}</h3>
        {onCompact && (
          <button
            type="button"
            onClick={compact}
            disabled={compacting}
            aria-describedby={`${id}-status`}
            className={cn(
              "rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors disabled:cursor-progress disabled:opacity-60",
              warn ? "bg-foreground text-background hover:opacity-90" : "bg-muted hover:bg-muted-foreground/15"
            )}
          >
            {compacting ? "Compacting…" : compactLabel}
          </button>
        )}
      </div>

      <div id={`${id}-status`} aria-live="polite" className="flex flex-col gap-1">
        <p className="flex flex-wrap items-baseline gap-x-2">
          <span className="text-4xl font-medium tracking-tight tabular-nums">
            <AnimatedTokens value={focused ? focused.tokens : used} instant={reduceMotion} />
          </span>
          <span className="text-sm text-muted-foreground">
            {focused ? `${focused.label.toLowerCase()} · ${Math.round((focused.tokens / limit) * 100)}%` : `of ${formatTokens(limit)}`}
          </span>
        </p>
        {warn && !focused && (
          <p className="text-xs font-medium text-amber-700 dark:text-amber-400">
            {formatTokens(free)} left — compact to keep earlier details from being dropped.
          </p>
        )}
      </div>

      <div
        role="meter"
        aria-label={`${title} window used`}
        aria-valuemin={0}
        aria-valuemax={limit}
        aria-valuenow={Math.min(used, limit)}
        aria-valuetext={`${formatTokens(used)} of ${formatTokens(limit)} tokens used`}
        className="flex h-11 w-full gap-1"
        onMouseLeave={() => setActive(null)}
      >
        {segments.map((s) => (
          <motion.div
            key={s.id}
            title={`${s.label}: ${formatTokens(s.tokens)}`}
            onMouseEnter={() => setActive(s.id)}
            className={cn("h-full min-w-0 rounded-md transition-opacity", s.color, active && active !== s.id && "opacity-30")}
            initial={false}
            animate={{ flexGrow: s.tokens, flexBasis: 0 }}
            style={{ flexShrink: 1, minWidth: s.tokens > 0 ? 4 : 0 }}
            transition={transition}
          />
        ))}
        <motion.div
          aria-hidden
          className="h-full rounded-md bg-muted"
          initial={false}
          animate={{ flexGrow: free, flexBasis: 0 }}
          transition={transition}
        />
      </div>

      <ul className="flex flex-wrap gap-2" aria-label="What's in context">
        {segments.map((s) => (
          <li key={s.id}>
            <button
              type="button"
              aria-pressed={active === s.id}
              onMouseEnter={() => setActive(s.id)}
              onMouseLeave={() => setActive(null)}
              onFocus={() => setActive(s.id)}
              onBlur={() => setActive(null)}
              onClick={() => setActive((a) => (a === s.id ? null : s.id))}
              className={cn(
                "flex items-center gap-2 rounded-full bg-muted px-3 py-1.5 text-sm transition-colors outline-none",
                "hover:bg-muted-foreground/15 focus-visible:ring-2 focus-visible:ring-ring",
                active === s.id && "bg-muted-foreground/15"
              )}
            >
              <span className={cn("size-2.5 rounded-[3px]", s.color)} aria-hidden />
              {s.label}
              <span className="sr-only">: {formatTokens(s.tokens)} tokens</span>
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

function AnimatedTokens({ value, instant }: { value: number; instant: boolean }) {
  const motionValue = useSpring(value, { stiffness: 220, damping: 30 });
  const text = useTransform(motionValue, (v) => formatTokens(v));

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

  return <motion.span>{text}</motion.span>;
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Context Usage

## Summary
A card that shows how full the model's context window is: a token count against the limit, a segmented bar of what's taking the space (chat, files, tools, system prompt, memory), a legend that picks out each part, and a Compact action that summarises older content to free room before the window runs out.

## When to use
- Long-running chats, coding agents and research sessions where the context fills up and the model starts forgetting or the product starts truncating.
- Products that let people add large inputs (files, repos, tool output) and need them to see the cost of each.
- Anywhere you offer compaction or summarisation, so people can choose when it happens instead of being surprised by it.

## When not to use
- Short, casual chats that never come close to the limit. A meter there is noise.
- To show what a run will cost in money or credits — use Cost Estimate.
- To show usage caps over time (messages per hour) — use Rate Limit.

## Anatomy
- Header: a title ("Context") and the Compact action.
- Total: the tokens used in large type ("175.6K"), with the limit beside it ("of 200K").
- Segmented bar: one block per source, sized by its share of the window, followed by a neutral block for free space. The whole bar is the window.
- Legend: one chip per source with its colour swatch and name.
- Low-space note: appears near the limit, saying how much is left and what compacting will do.

## Behavior
- Segments resize as content is added or removed, and the total animates to its new value.
- Hovering or focusing a legend chip (or hovering a segment) dims the other segments and swaps the total for that source's tokens and share ("39.2K · files · 20%").
- Past a threshold (90% by default) the low-space note appears and Compact becomes the primary action.
- Compact shows a pending state ("Compacting…"), then the bar shrinks as older turns are replaced by a summary and stale tool output is dropped. System prompt and memory are kept.
- Always offer compaction before the product does it on its own; when it does auto-compact, say so in the note.

## Content guidelines
- Format token counts compactly with one decimal ("175.6K", "1.2M"); never show raw counts like 175,612.
- Name sources in the user's terms (Chat, Files, Tools), not internal ones (messages array, tool_results).
- Keep the source list short — five or so. Fold rare sources into "Other".
- The low-space note says what's at stake, not just a number: "24.4K left — compact to keep earlier details from being dropped."

## Accessibility
- The bar is `role="meter"` with `aria-valuetext` ("175.6K of 200K tokens used").
- Legend chips are buttons with `aria-pressed` and a visually hidden token count, so each source can be inspected from the keyboard.
- The total and low-space note are in an `aria-live="polite"` region that also describes the Compact button.
- Colour is never the only cue: every segment has a labelled chip, and the warning is text.
- Animations are skipped when the user prefers reduced motion.

## Related patterns
- **Cost Estimate** — Use it instead when the constraint is credits or money for a single run, not space in the window.
- **Rate Limit** — Use it instead for caps on how much people can use over time.
- **Memory Panel** — Use it as well to let people see and edit what's kept across sessions, which also takes up context.

Do’s and don’ts

Do use it for

  • Long-running chats, coding agents and research sessions where the context fills up and the model starts forgetting or the product starts truncating.
  • Products that let people add large inputs (files, repos, tool output) and need them to see the cost of each.
  • Anywhere you offer compaction or summarisation, so people can choose when it happens instead of being surprised by it.

Don’t use it for

  • Short, casual chats that never come close to the limit. A meter there is noise.
  • To show what a run will cost in money or credits — use Cost Estimate.
  • To show usage caps over time (messages per hour) — use Rate Limit.