Context Limit Banner
A context-window warning that escalates from a quiet notice to a blocking wall — a thin usage meter with threshold ticks, rising-edge-only announcements, dismissal that re-arms on escalation, and the three exits out of a full conversation.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/context-limit-banner.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ContextLimitBanner" component (lucide-react
icons, a shadcn Button, cn() from the project's utils). It is the lifecycle
warning for a conversation running out of context window: one thin usage meter
plus the exits a long chat actually has.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
children; the remaining props spread onto the root.
- usedTokens: number — everything the conversation occupies right now (system
prompt + turns + attachments).
- maxTokens: number — the model's context window.
- attachmentTokens?: number (default 0) — a SUBSET of usedTokens, clamped to it.
- attachmentCount?: number — only used for the "3 attachments" wording.
- thresholds?: { notice?: number; warning?: number } — cut points on the 0–1
scale, default { notice: 0.7, warning: 0.9 }. Clamp both to [0,1] and sort
them, so a caller who swaps them gets a sane meter instead of nonsense.
- layout?: "auto" | "compact" | "full" (default "auto": compact at notice level,
full from warning up).
- dismissible?: boolean (default true), showWhenOk?: boolean (default false),
locale?: string (default "en-US").
- copy?: Partial<Record<Level, { title?: string; description?: string }>> and
actionLabels?: Partial<Record<ActionId, string>> — per-level overrides merged
over the defaults.
- onSummarize?, onStartNewChat?, onTrimAttachments?: () => void | Promise<void>.
A button exists only if its handler exists; the trim exit additionally needs
attachmentTokens > 0, because a button that would free nothing is a dead
button.
- onDismiss?(level), onLevelChange?(level, previous).
- Export the level machine as pure functions next to the component:
type ContextLimitLevel = "ok" | "notice" | "warning" | "blocked",
getContextLimitRatio(used, max) and
getContextLimitLevel(used, max, thresholds?).
The host gates its composer with getContextLimitLevel(...) === "blocked", so
the wall the reader sees and the wall the app enforces cannot drift apart.
Behavior — the level machine
- ratio = used / max, guarded: a non-finite or non-positive max yields ratio 0
and level "ok", and the component renders NOTHING rather than a NaN-wide bar.
Negative or non-finite used counts as 0.
- ratio >= 1 → "blocked"; >= warning → "warning"; >= notice → "notice"; else
"ok". Level "ok" renders nothing unless showWhenOk.
- Percentages never manufacture certainty: 99.6% prints 99%, a non-zero share
never prints 0%, and only a ratio that really reached 1 may print 100. Over
budget prints the true percentage (e.g. 103%) plus "over by 6.4K", while the
bar clamps at 100% and aria-valuenow clamps to max.
Behavior — escalation, announcements, dismissal
- Track the previous level in a ref. On a RISING edge: replay a one-shot flash
and push one sentence into a live region. On a FALLING edge: say nothing,
except when leaving "blocked" ("Context freed — 34% of the window in use"),
because being unblocked is worth hearing.
- Never make the banner itself a live region: usedTokens moves on every streamed
chunk, and a live banner would restart a screen reader hundreds of times per
answer. Keep two sr-only regions instead — role="status" aria-live="polite"
for notice/warning/failure, role="alert" for blocked — and mount them EVEN
WHILE THE BANNER IS HIDDEN, so the escalation that brings the banner into view
is announced instead of arriving inside a region that did not exist a frame
ago. Wrap each message in a keyed <span> so identical words re-announce.
- Dismissal is per level, not forever: remember the level it happened at, hide
the banner while the current level is <= that level, and clear the memory as
soon as the level falls BELOW it, so the next climb warns again. Waving away
the 70% notice must never hide the 100% wall; "blocked" offers no dismiss
button at all.
Behavior — actions
- One-shot lock: while any handler is in flight, every button gets aria-disabled
(NOT the native disabled attribute, which would throw focus away) and clicks
are dropped. Compaction is destructive; a double click must not run it twice.
Keep the lock itself in a ref, read and written synchronously inside the click
handler — the pending state only paints it, and two clicks that land in the
same task would both read the pre-click render and start the action twice.
- If a handler returns a thenable, enter a pending state: swap the icon for a
spinner and the label for "Summarizing…" / "Starting…" / "Trimming…". A
synchronous handler never enters that state — there is nothing to wait for and
nothing to settle if the parent unmounts the banner on the spot — which also
means it is never locked: work that must not run twice returns a promise, and
the pending label, the group lock and the failure line come with it.
- On rejection (or a synchronous throw), leave the pending state and show one
muted-destructive line that says what did NOT happen: "Couldn't summarize this
conversation. Nothing was changed — try again." Announce it through the polite
region and mark the visible copy aria-hidden so it is not read twice.
- Guard every promise settlement twice: an "alive" ref (re-armed on mount, so a
StrictMode remount does not leave it stuck) and a monotonic run id, so a stale
resolve can never clear the state of a newer run.
Rendering & styling
- Semantic tokens only: bg-card / border-border at notice level, bg-chart-3/10 +
border-chart-3/40 at warning, bg-destructive/10 + border-destructive/40 when
blocked; icon accents text-primary / text-chart-3 / text-destructive; meter
fills bg-primary / bg-chart-3 / bg-destructive with a /40 variant for the
attachment tail; track bg-muted; numbers text-muted-foreground. No hex, no
rgb(), no oklch().
- The meter is role="meter" with aria-valuemin/max/now and an aria-valuetext
that spells out "128,400 of 200,000 tokens — 64% of the context window".
role="meter" is children-presentational, so the visible "128.4K / 200K · 64%"
line is a SIBLING of it, never a child.
- Two stacked fills inside one track: total usage in the soft tone, the
conversation's own tokens (used - attachments) solid on top, so the attachment
tail is visibly the part the trim exit would remove. Two aria-hidden hairlines
mark the notice and warning cut points, so the reader can see how close the
next escalation is.
- Compact layout = icon + title + numbers + meter + first exit. Full layout adds
the description, the "3 attachments hold 26.8K tokens — 13% of the window"
line and every available exit, with the first one emphasised (Button variant
"default", the rest "outline").
- Motion: the fills transition width (500ms ease-out) with
motion-reduce:transition-none, the escalation flash is a decorative overlay
with motion-reduce:hidden, and the pending spinner is
motion-reduce:animate-none. No function depends on animation.
- Ship the flash keyframes in a React 19 hoisted <style href precedence> so no
Tailwind config edit is needed and duplicate instances dedupe by href.
- Intl.NumberFormat gets an explicit locale ("en-US" by default): a
runtime-default locale formats differently on the server and in the browser
and would hydrate mismatched.
Customization levers
- Cut points: thresholds={{ notice, warning }} moves both escalations; set
notice === warning to collapse to a single pre-block warning stage.
- Voice: copy per level and actionLabels per action swap every string without
touching the state machine ("Compact thread" / "Fork with recap" reads very
differently from "Summarize and continue").
- Exits: pass only the handlers your product actually has. No handlers at all
leaves an informational meter; trim disappears by itself when attachments hold
nothing.
- Density: layout="compact" keeps the banner one line tall even when blocked
(good for a composer header); layout="full" always shows the description (good
for an empty-thread panel). Root p-3 / text-sm and the meter's h-1.5 are the
only other spacing knobs.
- Palette: the warning stage is bg-chart-3 by convention — repoint it to any
chart token to match your project's semantics, and keep destructive for the
wall so it stays consistent with your other error surfaces.
- Reach: showWhenOk turns the same component into an always-on context readout;
dismissible={false} keeps every escalation on screen for regulated flows.
- Gate: reuse getContextLimitLevel for the composer, the send hotkey and any
server-side check, so all of them agree on where the wall is.Concepts
- Escalation thresholds — one ratio drives four named levels through two movable cut points; the banner changes what it is (quiet notice, full warning, wall) rather than only changing colour, so the wording, the density, the dismiss affordance and the number of exits all follow from the same number.
- Rising-edge announcement — the token count changes on every streamed chunk, so nothing is announced from the number itself: only a transition upward pushes a sentence into the sr-only region, which is why a screen-reader user hears "90% full" once instead of five hundred times per answer.
- Dismissal that re-arms — a dismissal is remembered as the level it happened at, not as a boolean; it suppresses that level and everything below it and is forgotten the moment usage drops beneath it, so waving away a 70% notice can never hide the 100% wall.
- Attachment tail — the meter draws the conversation's own tokens solid and the attached files in a lighter tone on top, so the "Trim attachments" exit points at a segment the reader can already see, and it disappears when that segment is empty.
- One-shot action lock — compaction is destructive and slow, so the first click owns the banner: every button goes aria-disabled (keeping focus where it was), later clicks are dropped, and a rejected handler reports what did not happen instead of leaving a half-compacted thread unexplained.
- Shared gate arithmetic — the level machine ships as a pure exported function, so the composer, the send hotkey and the banner all ask the same question; a wall the user can read but the app does not enforce (or the reverse) is the failure mode this avoids.
Chat Mode Picker
The composer's capability chips — web search, deep research, canvas, image — with a plan gate, a context gate, a cap, conflicting pairs, and a set that repairs itself when the model changes.
Agent Inbox
A human-in-the-loop queue of everything your agents parked on a person — triage ranking, overdue flags, one-shot inline Approve/Deny, grouping by agent or urgency, and four data states.