Aadi

Context Meter

AI chats

About

A context window meter for a chat composer that fills with each turn, turns amber then red, and shows when older turns get compacted.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Context Meter to your project and fits it to your theme.

Code

How Context Meter is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { ContextCompactionDivider, ContextMeter, Icon, IconNode, createIcon } from "@/components/ui/context-meter"

const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches

/** Per beat: how many turns are shown and what fills the window. */
const BEATS = [
  { turns: 2, breakdown: { system: 3_100, tools: 12_400, messages: 18_200, files: 14_300 } },
  { turns: 4, breakdown: { system: 3_100, tools: 12_400, messages: 44_600, files: 31_900 } },
  { turns: 6, breakdown: { system: 3_100, tools: 12_400, messages: 78_300, files: 46_200 } },
  { turns: 7, breakdown: { system: 3_100, tools: 12_400, messages: 96_100, files: 56_400 } },
  { turns: 8, breakdown: { system: 3_100, tools: 12_400, messages: 112_700, files: 64_800 } },
  { turns: 8, compacted: true, breakdown: { system: 3_100, tools: 12_400, messages: 14_900, files: 6_200 } },
] as const

const BEAT_MS = [1300, 1300, 1300, 1400, 1600, 3600]

const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"

/**
 * 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
 * phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
 * render white on dark pages. FitFrame fills the frame exactly instead.
 *
 * - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
 * - "crop": the content stays at full size from the top and fades out at the bottom. For text
 *   that streams in, where a changing scale would make the page breathe.
 *
 * `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
 * to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
 * shrinks evenly on both sides.
 *
 * Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
 * shorter than `hideBelow`, so a profile card shows the visual alone.
 */
function FitFrame({
  children,
  mode = "scale",
  hideBelow = 360,
  minWidth,
}: {
  children: React.ReactNode
  mode?: "scale" | "crop"
  hideBelow?: number
  minWidth?: string
}) {
  const content = React.useRef<HTMLDivElement>(null)
  const [cropped, setCropped] = React.useState(false)

  React.useLayoutEffect(() => {
    const el = content.current
    if (!el) return
    const measure = () => {
      const vw = window.innerWidth
      const vh = window.innerHeight
      el.toggleAttribute("data-fit-small", vh < hideBelow)
      // Layout sizes ignore the transform, so measuring never feeds back.
      const w = Math.max(el.offsetWidth, el.scrollWidth)
      const h = Math.max(el.offsetHeight, el.scrollHeight)
      const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
      el.style.transform = scale < 1 ? `scale(${scale})` : ""
      setCropped(mode === "crop" && h > vh + 1)
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(el)
    // Content that changes inside a fixed width box (a headline that cycles words) never resizes
    // the box itself, so changes below it measure again on the next frame.
    let frame = 0
    const mutations = new MutationObserver(() => {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(measure)
    })
    mutations.observe(el, { subtree: true, childList: true, characterData: true })
    window.addEventListener("resize", measure)
    return () => {
      observer.disconnect()
      mutations.disconnect()
      cancelAnimationFrame(frame)
      window.removeEventListener("resize", measure)
    }
  }, [mode, hideBelow])

  const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
  return (
    <div
      className="flex w-full justify-center overflow-hidden"
      style={{
        height: "100dvh",
        alignItems: mode === "crop" ? "flex-start" : "center",
        maskImage: cropped ? fade : undefined,
        WebkitMaskImage: cropped ? fade : undefined,
      }}
    >
      <style>{FIT_CSS}</style>
      <div
        ref={content}
        className="w-full shrink-0"
        style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
      >
        {children}
      </div>
    </div>
  )
}

interface Turn {
  from: "user" | "assistant"
  text: React.ReactNode
  /** Files the assistant read for this turn, shown as a chip. */
  files?: string
}

/** A long debugging session: each beat adds turns and fills the window. */
const TURNS: Turn[] = [
  { from: "user", text: "Why does the runs page flash blank before it loads?" },
  { from: "assistant", text: "The layout awaits the runs query, so nothing paints until it resolves.", files: "Read 6 files" },
  { from: "user", text: "Can we stream the table instead?" },
  { from: "assistant", text: "Yes. I moved the query into the table and wrapped it in Suspense.", files: "Edited 3 files" },
  { from: "user", text: "Now the header jumps when rows arrive." },
  { from: "assistant", text: "The skeleton had no fixed height. It now reserves 12 rows.", files: "Read 9 files" },
  { from: "user", text: "Run the e2e suite and fix whatever fails." },
  { from: "assistant", text: "Two specs waited on the old spinner. Both pass now.", files: "Ran 48 tests" },
]

const iconNode: IconNode = [
  ["path", {"d":"M7 2.5h5.88a1.5 1.5 0 0 1 1.06.44l5.12 5.12a1.5 1.5 0 0 1 .44 1.06V19a2.5 2.5 0 0 1-2.5 2.5H7A2.5 2.5 0 0 1 4.5 19V5A2.5 2.5 0 0 1 7 2.5"}],
  ["path", {"d":"M13.5 3.25v3.5a1.75 1.75 0 0 0 1.75 1.75h3.5"}],
  ["path", {"d":"m10 12-1.5 1.88a1 1 0 0 0 0 1.24L10 17"}],
  ["path", {"d":"m14 12 1.5 1.88a1 1 0 0 1 0 1.24L14 17"}],
]

const FileCodeIcon: Icon = /* @__PURE__ */ createIcon("file-code", iconNode)

const LIMIT = 200_000

const iconNode2: IconNode = [
  ["path", {"d":"m18.5 11.5-4.73-4.73a2.5 2.5 0 0 0-3.54 0L5.5 11.5"}],
  ["path", {"d":"M12 19v-9"}],
]

const ArrowUpIcon: Icon = /* @__PURE__ */ createIcon("arrow-up", iconNode2)

/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
  return (
    <p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
      {children}{" "}
      <a
        href={`https://chatcn.dev/docs/components/${slug}`}
        target="_blank"
        rel="noopener"
        className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
      >
        chatcn.dev
      </a>
    </p>
  )
}

/**
 * A coding thread fills its context window: the meter in the composer climbs with every turn,
 * turns amber past 80% and red past 95%, then the agent compacts and older turns fold into a
 * summary line. Loops. Click the meter for the breakdown by system, tools, messages and files.
 */
export default function ContextMeterPreview() {
  const [beat, setBeat] = React.useState(0)

  React.useEffect(() => {
    if (reducedMotion()) {
      setBeat(4)
      return
    }
    const id = window.setTimeout(() => setBeat((b) => (b + 1) % BEATS.length), BEAT_MS[beat])
    return () => window.clearTimeout(id)
  }, [beat])

  const now = BEATS[beat]!
  const compacted = "compacted" in now && now.compacted
  const freed = 3_100 + 12_400 + 112_700 + 64_800 - (3_100 + 12_400 + 14_900 + 6_200)

  return (
    <FitFrame minWidth="34rem">
      <div className="flex w-full flex-col items-center justify-center gap-4 p-6">
        <div className="rounded-(--chatcn-radius-lg) bg-(color:--chatcn-card) shadow-(--chatcn-shadow-sm) flex w-full max-w-xl flex-col">
          <div className="border-(color:--chatcn-border) flex items-center gap-2 border-b px-4 py-2.5">
            <span className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg) flex-1 truncate font-medium">Runs page loading</span>
            <span className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) font-mono">feature/runs-streaming</span>
          </div>
          {/* The thread keeps its height and shows its newest turns, the oldest fading at the top. */}
          <div className="relative h-60 overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_2.5rem)]">
            <div className="absolute inset-x-0 bottom-0 flex flex-col gap-2.5 px-4 pb-3">
              {TURNS.slice(0, now.turns).map((turn, i) => {
                const folded = compacted && i < now.turns - 2
                if (folded) return null
                return (
                  <div
                    // biome-ignore lint/suspicious/noArrayIndexKey: a fixed list.
                    key={i}
                    className={cn("flex flex-col gap-1", turn.from === "user" ? "items-end" : "items-start")}
                  >
                    {turn.files ? (
                      <span className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) inline-flex items-center gap-1.5">
                        <FileCodeIcon className="size-3.5" aria-hidden="true" />
                        {turn.files}
                      </span>
                    ) : null}
                    <p
                      className={cn(
                        "text-[0.8125rem]/[1.25rem] max-w-[85%] text-pretty",
                        turn.from === "user"
                          ? "bg-(color:--chatcn-fill) text-(color:--chatcn-fg) rounded-(--chatcn-radius-lg) px-3 py-1.5"
                          : "text-(color:--chatcn-fg-muted)",
                      )}
                    >
                      {turn.text}
                    </p>
                  </div>
                )
              })}
              {compacted ? (
                <ContextCompactionDivider freedTokens={freed} className="order-first" />
              ) : null}
            </div>
          </div>
          <div className="px-3 pb-3">
            <div className="rounded-(--chatcn-radius-lg) border-(color:--chatcn-border) flex flex-col gap-1 border px-3 pt-2.5 pb-2">
              <span className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg-faint)">Ask a follow up</span>
              <div className="flex items-center gap-2">
                <span className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) flex-1 font-mono">atlas-pro</span>
                <ContextMeter
                  limit={LIMIT}
                  breakdown={now.breakdown}
                  compacted={compacted ? { freedTokens: freed } : undefined}
                  model="atlas-pro"
                />
                <span className="bg-(color:--chatcn-fg) text-(color:--chatcn-bg) flex size-7 items-center justify-center rounded-full">
                  <ArrowUpIcon className="size-4" aria-hidden="true" />
                </span>
              </div>
            </div>
          </div>
        </div>
        <MoreOn slug="context-meter">How full the context window is, and when it compacts. On</MoreOn>
      </div>
    </FitFrame>
  )
}

Dependencies

More by Aadi

View all
  • Audio Visualizer

    Audio Visualizer

    12
  • Dither Orb

    Dither Orb

    12
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    7
  • Waveform

    Waveform

    5
  • Stat Card

    Stat Card

    4
  • Chat Search

    Chat Search

    3
  • Plan

    Plan

    3

More in AI chats

View all
  • AI Actions

    AI Actions

    339
  • Aquarium Prompt

    Aquarium Prompt

    58
  • Illos Knowledge Sources

    Illos Knowledge Sources

    42
  • Commit

    Commit

    34
  • Chat Bubble

    Chat Bubble

    199
  • Attachments

    Attachments

    39
  • Color Picker

    Color Picker

    173
  • Message Thread

    Message Thread

    58

Explore more

Part of chatcn — browse the full library on 21st.dev.