Ring or bar of context used, with a breakdown and a warning before full.
01Preview
Q3 planning with finance
Reply to Dana…
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @base-ui/react @number-flow/react03Usage
import { ContextMeter } from "@/components/ui/context-meter";
<ContextMeter
limit={200_000}
segments={[
{ id: "system", label: "System and tools", tokens: 6_200 },
{ id: "messages", label: "Messages", tokens: 58_400 },
{ id: "files", label: "Files", tokens: 41_300 },
]}
onCompact={() => summarizeOlderMessages(chatId)}
/>04Source
"use client";
import NumberFlow from "@number-flow/react";
import { Meter } from "@base-ui/react/meter";
import { Popover } from "@base-ui/react/popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/cn";
import { Loader, Warning } from "@/lib/icons";
import { ease } from "@/lib/motion";
/* -------------------------------------------------------------------------------------------------
* Helpers
* -----------------------------------------------------------------------------------------------*/
export type ContextSegment = {
id: string;
/** "System", "Messages", "Files", "Tools"… */
label: string;
tokens: number;
};
export type ContextLevel = "ok" | "warn" | "full";
/** 68400 → "68.4K", 200000 → "200K", 1048576 → "1M". The same on server and client. */
export function formatTokens(n: number) {
const abs = Math.abs(n);
const fmt = (v: number, unit: string) => `${v >= 100 ? Math.round(v) : Math.round(v * 10) / 10}${unit}`;
if (abs >= 1e6) return fmt(n / 1e6, "M");
if (abs >= 1e3) return fmt(n / 1e3, "K");
return String(Math.round(n));
}
/** Where usage stands against the thresholds. */
export function contextLevel(used: number, limit: number, warnAt = 0.8, fullAt = 0.95): ContextLevel {
const r = limit > 0 ? used / limit : 0;
return r >= fullAt ? "full" : r >= warnAt ? "warn" : "ok";
}
// Segments stack from strongest to faintest, so the eye reads them in order.
const shades = ["bg-fg", "bg-fg/60", "bg-fg/35", "bg-fg/20"];
const compact = { notation: "compact", maximumFractionDigits: 1 } as const;
/* -------------------------------------------------------------------------------------------------
* Meter
* -----------------------------------------------------------------------------------------------*/
export type ContextMeterProps = {
segments: ContextSegment[];
/** The model's context window, in tokens. */
limit: number;
variant?: "ring" | "bar";
/** Fraction of the window where it starts warning. */
warnAt?: number;
/** Fraction where it counts as full: older context starts being dropped or summarized. */
fullAt?: number;
/** Show the percentage beside the ring or bar. */
showLabel?: boolean;
/** Offer to summarize older messages once it warns. */
onCompact?: () => void | Promise<unknown>;
/** Controlled busy state for compacting, if it happens elsewhere. */
compacting?: boolean;
/** What happens at the limit, in one sentence. */
fullHint?: string;
side?: "top" | "bottom";
align?: "start" | "center" | "end";
container?: Popover.Portal.Props["container"];
className?: string;
};
export function ContextMeter({
segments,
limit,
variant = "ring",
warnAt = 0.8,
fullAt = 0.95,
showLabel = true,
onCompact,
compacting: compactingProp,
fullHint = "Older messages will be summarized to make room.",
side = "top",
align = "end",
container,
className,
}: ContextMeterProps) {
const reduce = !!useReducedMotion();
const used = segments.reduce((sum, s) => sum + Math.max(0, s.tokens), 0);
const ratio = limit > 0 ? Math.min(1, used / limit) : 0;
const pct = Math.round(ratio * 100);
const level = contextLevel(used, limit, warnAt, fullAt);
const [pending, setPending] = useState(false);
const compacting = compactingProp ?? pending;
// Crossing into a new level plays one soft ring, once. Going back down plays nothing.
const rank = { ok: 0, warn: 1, full: 2 }[level];
const [seen, setSeen] = useState({ level, pulse: 0 });
if (seen.level !== level) setSeen({ level, pulse: rank > { ok: 0, warn: 1, full: 2 }[seen.level] ? seen.pulse + 1 : seen.pulse });
const summary = `${formatTokens(used)} of ${formatTokens(limit)} tokens, ${pct}% used`;
return (
<Popover.Root>
<Popover.Trigger
openOnHover
delay={200}
closeDelay={120}
aria-label={`Context window: ${summary}`}
data-level={level}
data-variant={variant}
className={cn(
"group/meter relative inline-flex h-7 shrink-0 select-none items-center gap-1.5 rounded-md px-1.5 text-[12px] font-medium tabular text-fg-3 outline-none",
"touch-manipulation [-webkit-tap-highlight-color:transparent]",
"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg-2 active:scale-[0.96] active:duration-75",
"data-popup-open:bg-hover data-popup-open:text-fg-2",
"data-[level=warn]:text-warning data-[level=full]:text-danger",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden motion-reduce:active:scale-100",
className,
)}
>
{variant === "ring" ? <Ring ratio={ratio} level={level} pulse={seen.pulse} reduce={reduce} /> : <Bar ratio={ratio} />}
{showLabel && (
<span aria-hidden className="min-w-[3ch] text-right">
<NumberFlow value={pct} suffix="%" animated={!reduce} locales="en-US" className="tabular" />
</span>
)}
</Popover.Trigger>
<Popover.Portal container={container}>
<Popover.Positioner side={side} align={align} sideOffset={8} collisionPadding={12} className="z-(--z-popover)">
<Popover.Popup
className={cn(
"w-[min(18.5rem,calc(100vw-24px))] origin-(--transform-origin) rounded-xl border border-line-2 bg-raised p-3 text-fg shadow-pop outline-none",
"transition-[opacity,scale,translate] duration-160 ease-out-expo",
"data-starting-style:scale-96 data-starting-style:opacity-0",
"data-[side=top]:data-starting-style:translate-y-1 data-[side=bottom]:data-starting-style:-translate-y-1",
"data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none",
"motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0",
)}
>
<Meter.Root value={used} min={0} max={limit} getAriaValueText={() => summary} className="flex flex-col gap-2.5">
<div className="flex items-baseline justify-between gap-3">
<Meter.Label render={<Popover.Title />} className="text-[13px] font-medium tracking-[-0.01em]">
Context window
</Meter.Label>
<span className={cn("text-[12px] font-medium tabular", level === "ok" ? "text-fg-2" : level === "warn" ? "text-warning" : "text-danger")}>
<NumberFlow value={pct} suffix="%" animated={!reduce} locales="en-US" className="tabular" />
</span>
</div>
<Popover.Description className="-mt-2 text-[12px] tabular text-fg-3">
<NumberFlow value={used} format={compact} animated={!reduce} locales="en-US" className="tabular" /> of {formatTokens(limit)} tokens
</Popover.Description>
{/* One bar, a segment per kind of context, with the free space left as track. */}
<Meter.Track className="relative flex h-1.5 w-full gap-[2px] overflow-hidden rounded-full bg-fg/[0.08]">
{segments.map((s, i) => (
<span
key={s.id}
aria-hidden
className={cn("h-full shrink-0 transition-[width] duration-500 ease-out-quart motion-reduce:transition-none", shades[Math.min(i, shades.length - 1)])}
style={{ width: `${(Math.max(0, s.tokens) / limit) * 100}%` }}
/>
))}
{/* The threshold, marked on the track so "near the limit" has a place. */}
<span aria-hidden className="absolute inset-y-0 w-px bg-warning/70" style={{ left: `${fullAt * 100}%` }} />
</Meter.Track>
<ul className="flex flex-col gap-1 text-[12px]">
{segments.map((s, i) => (
<li key={s.id} className={cn("flex items-center gap-2", s.tokens <= 0 && "opacity-50")}>
<span aria-hidden className={cn("size-2 shrink-0 rounded-[2px]", shades[Math.min(i, shades.length - 1)])} />
<span className="min-w-0 flex-1 truncate text-fg-2">{s.label}</span>
<span className="tabular text-fg">{formatTokens(s.tokens)}</span>
<span className="w-9 text-right tabular text-fg-4">{limit ? Math.round((s.tokens / limit) * 100) : 0}%</span>
</li>
))}
<li className="flex items-center gap-2 border-t border-line pt-1.5">
<span aria-hidden className="size-2 shrink-0 rounded-[2px] border border-line-2" />
{used > limit ? (
// Past the window, the honest number is how far over it is.
<>
<span className="min-w-0 flex-1 text-danger">Over the limit by</span>
<span className="tabular text-danger">{formatTokens(used - limit)}</span>
<span className="w-9" />
</>
) : (
<>
<span className="min-w-0 flex-1 text-fg-3">Free</span>
<span className="tabular text-fg-2">{formatTokens(limit - used)}</span>
<span className="w-9 text-right tabular text-fg-4">{100 - pct}%</span>
</>
)}
</li>
</ul>
</Meter.Root>
<AnimatePresence initial={false}>
{level !== "ok" && (
<motion.div
key="warning"
initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.16, ease: ease.in } }}
transition={{ duration: 0.24, ease: ease.out }}
className="overflow-hidden"
>
<div className={cn("mt-3 flex flex-col gap-2 rounded-lg p-2.5 text-[12px] leading-[1.45]", level === "warn" ? "bg-warning-soft" : "bg-danger-soft")}>
<p className="flex gap-2">
<Warning size={14} className={cn("mt-px shrink-0", level === "warn" ? "text-warning" : "text-danger")} />
<span className="text-fg-2">
<span className="font-medium text-fg">{level === "warn" ? "Nearing the limit." : "Context is full."}</span> {fullHint}
</span>
</p>
{onCompact && (
<button
type="button"
aria-busy={compacting || undefined}
onClick={() => {
if (compacting) return;
const r = onCompact();
if (r && typeof (r as Promise<unknown>).then === "function") {
setPending(true);
(r as Promise<unknown>).finally(() => setPending(false));
}
}}
className={cn(
"relative ms-6 inline-grid h-7 w-fit place-items-center rounded-md border border-line-2 bg-raised px-2.5 text-[12px] font-medium text-fg shadow-[var(--shadow)] outline-none",
"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97]",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 aria-busy:cursor-progress",
)}
>
{/* Both labels share one cell, so the button keeps its width while busy. */}
<span className={cn("col-start-1 row-start-1 transition-opacity duration-150", compacting && "opacity-0")}>Summarize older messages</span>
<span aria-hidden={!compacting} className={cn("col-start-1 row-start-1 flex items-center gap-1.5 transition-opacity duration-150", !compacting && "opacity-0")}>
<Loader size={14} className="animate-spin-slow" />
Summarizing…
</span>
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
/* -------------------------------------------------------------------------------------------------
* Glyphs
* -----------------------------------------------------------------------------------------------*/
function Ring({ ratio, level, pulse, reduce }: { ratio: number; level: ContextLevel; pulse: number; reduce: boolean }) {
const r = 6;
const c = 2 * Math.PI * r;
return (
<span className="relative grid size-4 place-items-center">
<svg width={16} height={16} viewBox="0 0 16 16" aria-hidden className="-rotate-90">
<circle cx="8" cy="8" r={r} fill="none" strokeWidth={2} className="stroke-fg/15" />
<circle
cx="8"
cy="8"
r={r}
fill="none"
strokeWidth={2}
strokeLinecap="round"
stroke="currentColor"
strokeDasharray={c}
// A sliver always shows once anything is used, so a new chat still reads as a meter.
strokeDashoffset={c * (1 - Math.max(ratio, ratio > 0 ? 0.04 : 0))}
className={cn(
"transition-[stroke-dashoffset,color] duration-600 ease-out-quart motion-reduce:transition-none",
level === "ok" ? "text-fg-2" : level === "warn" ? "text-warning" : "text-danger",
)}
/>
</svg>
{/* Crossing a threshold rings once, like a soft bell, then stays quiet. */}
<AnimatePresence>
{pulse > 0 && !reduce && (
<motion.span
key={pulse}
aria-hidden
className={cn("pointer-events-none absolute inset-0 rounded-full border-2", level === "full" ? "border-danger" : "border-warning")}
initial={{ scale: 1, opacity: 0.6 }}
animate={{ scale: 2.1, opacity: 0 }}
transition={{ duration: 0.9, ease: ease.out }}
/>
)}
</AnimatePresence>
</span>
);
}
function Bar({ ratio }: { ratio: number }) {
return (
<span className="relative h-1 w-12 overflow-hidden rounded-full bg-fg/15">
<span
className="absolute inset-y-0 left-0 rounded-full bg-current transition-[width] duration-500 ease-out-quart motion-reduce:transition-none"
style={{ width: `${Math.max(ratio, ratio > 0 ? 0.03 : 0) * 100}%` }}
/>
</span>
);
}05Props
ContextMeter
| Prop | Type | Default | Description |
|---|---|---|---|
| segments* | ContextSegment[] | — | { id, label, tokens } per kind of context, in the order to stack them. |
| limit* | number | — | The model's context window, in tokens. |
| variant | "ring" | "bar" | "ring" | A 16px ring for a composer, or a 48px bar for a header. |
| warnAt | number | 0.8 | Fraction of the window where it turns amber and explains. |
| fullAt | number | 0.95 | Fraction where it counts as full; marked on the breakdown bar. |
| showLabel | boolean | true | The percentage beside the glyph. |
| onCompact | () => void | Promise<unknown> | — | Shows a Summarize older messages action once it warns. A promise shows the busy state until it settles. |
| compacting | boolean | — | Controlled busy state for that action. |
| fullHint | string | "Older messages will be summarized to make room." | What happens at the limit, in one sentence. |
| side | "top" | "bottom" | "top" | Where the breakdown opens. |
| align | "start" | "center" | "end" | "end" | Breakdown alignment against the trigger. |
| container | HTMLElement | RefObject | — | Portal target. Defaults to document.body. |
formatTokens / contextLevel
| Prop | Type | Default | Description |
|---|---|---|---|
| formatTokens(n) | (n: number) => string | — | 68400 → 68.4K. Identical on server and client. |
| contextLevel(used, limit, warnAt?, fullAt?) | "ok" | "warn" | "full" | — | The same thresholds, for your own UI (disabling attachments, say). |
06Notes
Behavior
- Hover opens the breakdown after 200ms; a tap or Enter opens it too, so it works on touch and from the keyboard.
- A new chat still shows a sliver of arc, so the meter reads as a meter rather than an empty circle.
- Past the window the percentage stops at 100 and the breakdown says how far over it is instead of showing zero free.
- The summarize action keeps its width while busy: both labels share one grid cell.
Motion
- The arc and bar ease to new values over 500–600ms on the quart ease-out and change color with them; the percentage and token count roll.
- Crossing into warning or full rings the glyph once (scale to 2.1 and fade, 900ms). Dropping back plays nothing.
- The breakdown grows from the trigger in 160ms; the warning block opens by height in 240ms. Reduced motion drops the ring, the slide and the growth and keeps fades.
Accessibility
- The trigger's name carries the whole reading: Context window: 68.4K of 200K tokens, 34% used.
- The breakdown is a labelled popover with a real meter (role meter, value text in tokens).
- Level is never color alone: the warning names itself in words and the thresholds are in the text.