# Context Meter

> Show how full the model's context window is, with the exact token counts one press away.

Source: https://ai.honestui.com/docs/components/context-meter

```tsx
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { ContextMeter } from "@/components/ai/context-meter"

// Sample figures.
const SAMPLE_LIMIT = 200_000
const SAMPLE_FILE_TOKENS = 40_000
const sampleBase = { system: 2_100, messages: 61_300, files: 20_800 }

/**
 * No model or tokenizer is counting here. The numbers are samples, and the
 * button adds a 40,000-token file so the meter passes its two thresholds.
 */
export function ContextMeterDemo() {
  const [addedFiles, setAddedFiles] = React.useState(0)
  const files = sampleBase.files + addedFiles * SAMPLE_FILE_TOKENS
  const used = sampleBase.system + sampleBase.messages + files

  return (
    <div className="flex w-full max-w-md min-w-0 flex-col items-start gap-[var(--hui-space-4)]">
      <ContextMeter
        used={used}
        limit={SAMPLE_LIMIT}
        parts={[
          { label: "System prompt", tokens: sampleBase.system },
          { label: "Messages", tokens: sampleBase.messages },
          { label: "Files", tokens: files },
        ]}
        cost="$0.42"
        note="Sample figures. Nothing is counting tokens in this preview."
      />
      <div className="flex flex-wrap gap-[var(--hui-space-2)]">
        <Button variant="secondary" size="sm" onClick={() => setAddedFiles(addedFiles + 1)}>
          Add a 40,000-token file
        </Button>
        <Button variant="link" size="sm" disabled={addedFiles === 0} onClick={() => setAddedFiles(0)}>
          Reset
        </Button>
      </div>
    </div>
  )
}

```

## Overview [#overview]

A long conversation fills the model's context window, and what happens next, whether a reply fails or older messages are dropped, usually comes as a surprise. Context Meter shows the limit before it is reached: a small ring and a percentage, which open the exact numbers.

It counts nothing. `used` and `limit` are figures your model or tokenizer reported, and the meter does the division. It does not know your prices either, so `cost` is a string you have already formatted.

## Anatomy [#anatomy]

<Anatomy title="ContextMeter">
  <AnatomyItem name="Trigger" description="A ring, the percentage when it is shown, and a warning mark near the limit" />

  <AnatomyItem name="Details" description="The popover the trigger opens">
    <AnatomyItem name="Bar" description="The share of the window in use" />

    <AnatomyItem name="Counts" description="Tokens used, the limit, and tokens left" />

    <AnatomyItem name="Status" description="Near the limit, or at it" />

    <AnatomyItem name="Parts" description="What the tokens are made of, and the cost, when you pass them" />

    <AnatomyItem name="Note" description="Your own line of explanation" />
  </AnatomyItem>
</Anatomy>

`ContextMeter` is one component. Installing `context-meter` brings [Popover](https://www.honestui.com/docs/components/popover) and the shared easing constants in `lib/ease` along as registry dependencies, and installs `motion`.

## Behavior [#behavior]

### Percentage [#percentage]

The percentage is `used` divided by `limit`, rounded down, so 99.6% reads as 99% and "100%" appears only when the window is full. Past the limit the real figure is shown, such as 104%, and the ring and the bar stay full.

A `limit` of zero or less shows 0%. Do not render the meter until you know the limit.

### Motion [#motion]

When `used` or `limit` changes, the ring, the percentage, and the bar glide from the old figure to the new one over 0.6 seconds, and the percentage counts through the values between. The status, and the name read by assistive technology, change at once. The meter does not animate when it first appears.

### Ring only [#ring-only]

Set `showPercent` to `false` to show the ring without the number, for a row with little room. The figure is still in the button's name and in the details.

### Status [#status]

The meter has three states, reported as `data-status` on the trigger.

| Status | When                                        | What changes                                                       |
| ------ | ------------------------------------------- | ------------------------------------------------------------------ |
| `ok`   | Below `nearAt`                              | Nothing                                                            |
| `near` | At or above `nearAt`, which defaults to 0.8 | A warning mark on the trigger, and "Near the limit" in the details |
| `full` | At or above the limit                       | The same, in the danger color, with "At the limit"                 |

The meter reports the state. It does not say what your product does at the limit, because it cannot know. Use `note` to say it: that older messages will be summarized, or that the person should start a new conversation.

### Parts and cost [#parts-and-cost]

`parts` lists what the tokens are made of. The meter shows the numbers you pass and does not check that they add up to `used`. `cost` adds one more row.

### Placement [#placement]

The trigger is sized to sit in a row of controls. In a [Prompt Bar](/docs/components/prompt-bar), pass it to `actions`.

## Accessibility [#accessibility]

The trigger is a button. Its name is the summary, such as "42% of context used", followed by the status when there is one. The ring and the visible percentage are hidden from assistive technology, so the figure is read once.

The warning is a mark and a phrase as well as a color, and the ring is drawn with the text color, so it holds up in forced colors.

With reduced motion, the ring, the percentage, and the bar change with no glide.

The details open in a popover that takes focus and closes on <kbd>Escape</kbd>, returning focus to the trigger. The bar has the `meter` role and is read as the token counts, not as a bare percentage.

The trigger is not a live region, so a change in the percentage is not announced. If crossing a threshold matters in your product, announce it yourself, once, when it happens.

Every string comes from `labels`, and `labels.number` formats every token count. The default formats numbers for English.

The preview has been checked with a keyboard and with axe. It has not been checked with a screen reader yet, and support for the `meter` role varies across them.

| Key                                  | Action                    |
| ------------------------------------ | ------------------------- |
| <kbd>Enter</kbd> or <kbd>Space</kbd> | Open or close the details |
| <kbd>Escape</kbd>                    | Close the details         |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;@honestai/context-meter&#x22;]" />
  

  
    
      
        Add what it is built from.
      

      <CliBlock commands="[&#x22;popover&#x22;, &#x22;ease&#x22;]" />

      
        Install Motion.
      

      ```bash
      npm install motion
      ```

      
        Copy and paste this file into your project.
      

      ### components/ai/context-meter.tsx

```tsx
"use client"

import * as React from "react"
import { TriangleAlert as TriangleAlertIcon } from "honestui/icons"
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"

import { EASE_OUT } from "@/lib/ease"
import { cn } from "@/lib/utils"
import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover"

export type ContextMeterStatus = "ok" | "near" | "full"

export type ContextMeterPart = {
  /** What these tokens are, such as "Messages" or "Files". */
  label: string
  tokens: number
}

export type ContextMeterLabels = {
  title: string
  /** The trigger's name, such as "42% of context used". */
  summary: (percent: number) => string
  tokens: (used: string, limit: string) => string
  remaining: (tokens: string) => string
  near: string
  full: string
  cost: string
  /** Formats every token count. Replace it to match the person's locale. */
  number: (value: number) => string
}

const numberFormat = new Intl.NumberFormat("en-US")

export const defaultContextMeterLabels: ContextMeterLabels = {
  title: "Context window",
  summary: (percent) => `${percent}% of context used`,
  tokens: (used, limit) => `${used} of ${limit} tokens`,
  remaining: (tokens) => `${tokens} left`,
  near: "Near the limit",
  full: "At the limit",
  cost: "Cost",
  number: (value) => numberFormat.format(value),
}

const RING_RADIUS = 6
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
const CHANGE_SECONDS = 0.6

// The icon's dot is a line too short to see until its ends are rounded.
const warningStroke = { strokeWidth: 1.5, strokeLinecap: "round" } as const

// Round down below the limit, so "100%" is shown only when the window is full.
const toPercent = (ratio: number) => (ratio >= 1 ? Math.round(ratio * 100) : Math.floor(ratio * 100))

const statusTone: Record<ContextMeterStatus, string> = {
  ok: "text-[var(--hui-color-foreground-base-primary)]",
  near: "text-[var(--hui-color-foreground-base-primary)]",
  // The darker danger step, which stays readable as small text.
  full: "text-[var(--hui-color-foreground-danger-primary-hover)]",
}

export type ContextMeterProps = Omit<React.ComponentProps<"button">, "children"> & {
  /** Tokens in the context now. */
  used: number
  /** The size of the model's context window, in tokens. */
  limit: number
  /** What the tokens are made of. Listed in the details. */
  parts?: ContextMeterPart[]
  /** A cost you have already formatted, such as "$0.42". The meter does no pricing. */
  cost?: string
  /** The share of the limit, from 0 to 1, at which the meter says it is near the limit. */
  nearAt?: number
  /** Set false to show the ring without the percentage beside it. */
  showPercent?: boolean
  /** A line under the numbers, such as what your product does when the window fills. */
  note?: React.ReactNode
  labels?: Partial<ContextMeterLabels>
}

/**
 * How full a model's context window is, as a small ring and a percentage that
 * open the exact numbers. It counts nothing itself: `used` and `limit` are the
 * figures your model or tokenizer reported. A change glides from the old
 * figure to the new one.
 */
function ContextMeter({
  used,
  limit,
  parts = [],
  cost,
  nearAt = 0.8,
  showPercent = true,
  note,
  labels: labelsProp,
  className,
  ...props
}: ContextMeterProps) {
  const labels = { ...defaultContextMeterLabels, ...labelsProp }
  const titleId = React.useId()

  const usedTokens = Math.max(used, 0)
  const ratio = limit > 0 ? usedTokens / limit : 0
  const percent = toPercent(ratio)
  const status: ContextMeterStatus = ratio >= 1 ? "full" : ratio >= nearAt ? "near" : "ok"
  const statusLabel = status === "full" ? labels.full : status === "near" ? labels.near : null

  // What the ring, the number, and the bar show while they move to a new figure.
  // The name read by assistive technology changes at once, with no steps between.
  const shouldReduceMotion = useReducedMotion() ?? false
  const shownRatio = useMotionValue(ratio)
  React.useEffect(() => {
    if (shouldReduceMotion) {
      shownRatio.set(ratio)
      return
    }
    const controls = animate(shownRatio, ratio, { duration: CHANGE_SECONDS, ease: EASE_OUT })
    return () => controls.stop()
  }, [ratio, shouldReduceMotion, shownRatio])
  const ringDash = useTransform(
    shownRatio,
    (value) => `${Math.min(value, 1) * RING_CIRCUMFERENCE} ${RING_CIRCUMFERENCE}`
  )
  const shownPercent = useTransform(shownRatio, (value) => `${toPercent(value)}%`)
  const barWidth = useTransform(shownRatio, (value) => `${Math.min(value, 1) * 100}%`)
  const tokensText = labels.tokens(labels.number(usedTokens), labels.number(limit))

  return (
    <Popover>
      <PopoverTrigger
        data-slot="context-meter"
        data-status={status}
        className={cn(
          "inline-flex shrink-0 cursor-pointer items-center gap-[var(--hui-space-2)] rounded-[var(--hui-radius-2)] border-0 bg-transparent px-[var(--hui-space-2)] py-[var(--hui-space-2)] tabular-nums outline-none [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)] motion-safe:[transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)] data-popup-open:bg-[var(--hui-color-background-base-primary-hover)]",
          statusTone[status],
          className
        )}
        {...props}
      >
        <svg aria-hidden="true" viewBox="0 0 16 16" className="size-4 shrink-0 -rotate-90">
          <circle cx="8" cy="8" r={RING_RADIUS} fill="none" stroke="currentColor" strokeWidth="2" opacity="0.2" />
          <motion.circle
            cx="8"
            cy="8"
            r={RING_RADIUS}
            fill="none"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            style={{ strokeDasharray: ringDash }}
          />
        </svg>
        {showPercent && (
          <motion.span aria-hidden="true" data-slot="context-meter-percent">
            {shownPercent}
          </motion.span>
        )}
        {/* The warning is a shape and a word, so it does not depend on the color. */}
        {statusLabel && <TriangleAlertIcon {...warningStroke} aria-hidden="true" className="size-3.5 shrink-0" />}
        <span className="sr-only">
          {labels.summary(percent)}
          {statusLabel && `. ${statusLabel}`}
        </span>
      </PopoverTrigger>
      <PopoverPopup align="end" aria-labelledby={titleId} className="w-64 max-w-[calc(100vw-2rem)]">
        <div data-slot="context-meter-details" className="flex flex-col gap-[var(--hui-space-3)]">
          <p id={titleId} className="m-0 [font-weight:var(--hui-font-weight-medium)]">
            {labels.title}
          </p>

          <div className="flex flex-col gap-[var(--hui-space-2)]">
            <div
              role="meter"
              aria-labelledby={titleId}
              aria-valuemin={0}
              aria-valuemax={limit}
              aria-valuenow={Math.min(usedTokens, limit)}
              aria-valuetext={tokensText}
              className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--hui-color-background-base-secondary)]"
            >
              {/* A border, not a fill, so the bar survives forced colors. */}
              <motion.div
                className={cn("h-full rounded-full border-t-[6px] border-current", statusTone[status])}
                style={{ width: barWidth }}
              />
            </div>
            <p className="m-0 flex flex-wrap items-baseline justify-between gap-x-[var(--hui-space-3)]">
              <span>{tokensText}</span>
              <span className="text-[var(--hui-color-foreground-base-secondary)]">
                {labels.remaining(labels.number(Math.max(limit - usedTokens, 0)))}
              </span>
            </p>
            {statusLabel && (
              <p className={cn("m-0 flex items-center gap-[var(--hui-space-2)]", statusTone[status])}>
                <TriangleAlertIcon {...warningStroke} aria-hidden="true" className="size-3.5 shrink-0" />
                <span className="[font-weight:var(--hui-font-weight-medium)]">{statusLabel}</span>
              </p>
            )}
          </div>

          {(parts.length > 0 || cost !== undefined) && (
            <dl className="m-0 grid grid-cols-[minmax(0,1fr)_auto] gap-x-[var(--hui-space-4)] gap-y-[var(--hui-space-1)] border-t-[0.5px] border-[var(--hui-color-border-base-primary)] pt-[var(--hui-space-3)]">
              {parts.map((part) => (
                <React.Fragment key={part.label}>
                  <dt className="min-w-0 break-words text-[var(--hui-color-foreground-base-secondary)]">
                    {part.label}
                  </dt>
                  <dd className="m-0 text-end tabular-nums">{labels.number(part.tokens)}</dd>
                </React.Fragment>
              ))}
              {cost !== undefined && (
                <>
                  <dt className="text-[var(--hui-color-foreground-base-secondary)]">{labels.cost}</dt>
                  <dd className="m-0 text-end tabular-nums">{cost}</dd>
                </>
              )}
            </dl>
          )}

          {note && (
            <p className="m-0 break-words text-[var(--hui-color-foreground-base-secondary)]">{note}</p>
          )}
        </div>
      </PopoverPopup>
    </Popover>
  )
}

export { ContextMeter }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { ContextMeter } from "@/components/ai/context-meter"

<ContextMeter used={usage.inputTokens} limit={model.contextWindow} />
```

With the details filled in:

```tsx
<ContextMeter
  used={usage.inputTokens}
  limit={model.contextWindow}
  parts={[
    { label: "Messages", tokens: usage.messageTokens },
    { label: "Files", tokens: usage.fileTokens },
  ]}
  cost={formatCurrency(usage.cost)}
  note="When the window fills, the oldest messages are summarized."
/>
```

## Examples [#examples]

### In a Prompt Bar [#in-a-prompt-bar]

The meter in the bar's `actions`, starting near the limit. Sending a prompt adds a sample count to it.

```tsx
"use client"

import * as React from "react"

import { ContextMeter } from "@/components/ai/context-meter"
import { PromptBar } from "@/components/ai/prompt-bar"

// Sample figures. Each sent prompt is counted as one token for every four characters, a rough stand-in for a tokenizer.
const SAMPLE_LIMIT = 8_000
const SAMPLE_USED = 6_150

/** No model replies here. Sending a prompt adds its sample token count to the meter. */
export function ContextMeterPromptBarExample() {
  const [used, setUsed] = React.useState(SAMPLE_USED)

  return (
    <div className="w-full max-w-md min-w-0">
      <PromptBar
        placeholder="Write a message…"
        onSubmit={({ text }) => setUsed((current) => current + Math.ceil(text.length / 4))}
        actions={<ContextMeter used={used} limit={SAMPLE_LIMIT} />}
      />
    </div>
  )
}

```

### Ring only [#ring-only-1]

The meter with `showPercent` set to `false`. The button adds sample tokens, so the ring can be seen moving.

```tsx
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { ContextMeter } from "@/components/ai/context-meter"

// Sample figures.
const SAMPLE_LIMIT = 200_000
const SAMPLE_STEP = 30_000

/** Nothing is counting tokens here. The button adds a sample 30,000 tokens. */
export function ContextMeterRingExample() {
  const [used, setUsed] = React.useState(48_000)

  return (
    <div className="flex w-full max-w-md min-w-0 items-center gap-[var(--hui-space-3)]">
      <ContextMeter used={used} limit={SAMPLE_LIMIT} showPercent={false} />
      <Button variant="secondary" size="sm" onClick={() => setUsed(used + SAMPLE_STEP)}>
        Add 30,000 tokens
      </Button>
      <Button variant="link" size="sm" disabled={used === 48_000} onClick={() => setUsed(48_000)}>
        Reset
      </Button>
    </div>
  )
}

```

## API reference [#api-reference]

### ContextMeter [#contextmeter]

Accepts native button props beyond those listed.

| Prop          | Type                          | Default  | Description                                                              |
| ------------- | ----------------------------- | -------- | ------------------------------------------------------------------------ |
| `used`        | number                        | Required | Tokens in the context now.                                               |
| `limit`       | number                        | Required | The size of the context window, in tokens.                               |
| `parts`       | `ContextMeterPart[]`          | None     | What the tokens are made of.                                             |
| `cost`        | string                        | None     | A cost you have already formatted.                                       |
| `nearAt`      | number                        | `0.8`    | The share of the limit at which the meter says it is near the limit.     |
| `showPercent` | boolean                       | `true`   | Shows the percentage beside the ring.                                    |
| `note`        | ReactNode                     | None     | A line under the numbers.                                                |
| `labels`      | `Partial<ContextMeterLabels>` | English  | The title, the summary, the counts, the statuses, and the number format. |

### Types [#types]

| Export               | Shape                                                                       |
| -------------------- | --------------------------------------------------------------------------- |
| `ContextMeterPart`   | `label` and `tokens`                                                        |
| `ContextMeterStatus` | `"ok"`, `"near"`, or `"full"`                                               |
| `ContextMeterLabels` | `title`, `summary`, `tokens`, `remaining`, `near`, `full`, `cost`, `number` |
