Usage meter

Credits, messages or context left: quiet when there’s plenty, clear before it runs out.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/usage-meter.json

Overview

Running out mid-task is the worst way to learn about a limit. The meter stays quiet while there’s plenty, with numbers that roll as they’re spent; it turns amber and opens the way to upgrade once a fifth is left; and when you’re out the numbers fold away as the words glide into "No credits left" and "Resets Oct 12" morphs into "You’re out until Oct 12." Use the bar in settings and menus, or the ring, small enough for a composer toolbar, for how full the context window is.

States

normal
"1,240 of 2,000 credits left" over a neutral bar, and when it resets. Spending rolls the numbers (700ms) and eases the bar.
low
The bar turns amber once a fifth (lowAt) is left and the Upgrade link opens in beside the reset date.
out
Red. The numbers fold away while "credits left" morphs into "No credits left"; the footer morphs to "You’re out until Oct 12."
ring
A ring that fills and a percent that rolls; amber when low, red when full.

Usage

import { UsageMeter } from "@/components/usage-meter";

<UsageMeter used={760} limit={2000} unit="credits" resetAt={resetAt} onUpgrade={openPricing} />
<UsageMeter variant="ring" label="Context" unit="tokens" used={54_000} limit={128_000} />

Works with

With the AI SDK

Map the Vercel AI SDK’s message parts and chat status onto the component’s props.

// app/api/chat/route.ts: attach token usage to each answer
return result.toUIMessageStreamResponse({
  messageMetadata: ({ part }) =>
    part.type === "finish" ? { totalTokens: part.totalUsage.totalTokens } : undefined,
});

// The composer toolbar: how full the context window is
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "ai";
import { UsageMeter } from "@/components/usage-meter";

type Message = UIMessage<{ totalTokens?: number }>;

function ContextMeter() {
  const { messages } = useChat<Message>();
  // The last answer's total covers everything sent with it plus what it wrote.
  const used = messages.findLast((m) => m.role === "assistant")?.metadata?.totalTokens ?? 0;
  return <UsageMeter variant="ring" label="Context" unit="tokens" used={used} limit={128_000} />;
}

Props

PropTypeDescription
used / limitnumberHow much is used, out of how much.
unit / labelstringdefault "credits" / "Usage"What’s counted, and the meter’s name.
resetAtnumber | DateWhen it resets; shown under the bar and in the out state.
variant"bar" | "ring"default "bar"A full-width bar, or a 16px ring with a percentage.
lowAtnumberdefault 0.2Share left at which it turns amber.
onUpgrade / upgradeLabel() => void / stringdefault "Upgrade"Shown when low or out.

Accessibility and motion

  • role="meter" with aria-valuetext like "1,240 of 2,000 credits left", so the number is read, not a percentage.
  • Colour is never the only signal: the text changes too, and the ring has a title with the full summary.
  • Numbers are mono and tabular, so they don’t jitter as they roll. The rolling text is decorative; the meter’s aria-valuetext carries the real figures.
  • Installs Number roll and Text morph. With reduced motion the bar, ring and numbers change in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.

The CLI also installs Number roll, Text morph.

Source

›Show the full source of usage-meter.tsx (184 lines)
"use client";

import * as React from "react";
import { NumberRoll } from "./number-roll";
import { TextMorph } from "./text-morph";

/* ─────────────────────────────────────────────────────────
 * USAGE METER: how much is left, before it runs out
 *
 *   normal  quiet: "1,240 of 2,000 left"; the numbers roll as
 *           they're spent and the bar eases to match
 *   low     amber once a fifth is left; the way to upgrade
 *           opens in beside the reset date
 *   out     red; the numbers fold away as the words morph to
 *           "No credits left", and "Resets Oct 12" becomes
 *           "You're out until Oct 12."
 *
 * A bar for settings and menus, or a ring small enough for a
 * composer toolbar (context windows, daily limits).
 * ───────────────────────────────────────────────────────── */

export interface UsageMeterProps extends React.HTMLAttributes<HTMLDivElement> {
  used: number;
  limit: number;
  /** What's being counted, e.g. "credits" or "messages". */
  unit?: string;
  label?: string;
  /** When it resets (ms or Date). */
  resetAt?: number | Date;
  variant?: "bar" | "ring";
  /** Share left at which it turns amber. */
  lowAt?: number;
  onUpgrade?: () => void;
  upgradeLabel?: string;
}

const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";
const MORPH = "cubic-bezier(0.16,1,0.3,1)";
const number = new Intl.NumberFormat("en-US");
const GROUPED = { useGrouping: true };

const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
  const query = window.matchMedia(reducedQuery);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);

/** A piece of a line that opens out of nothing and folds back into it. */
function Reveal({ show, reduced, className = "", children }: { show: boolean; reduced: boolean; className?: string; children: React.ReactNode }) {
  return (
    <span
      aria-hidden={!show || undefined}
      className={`grid ${className}`}
      style={{
        gridTemplateColumns: show ? "1fr" : "0fr",
        opacity: show ? 1 : 0,
        filter: show ? "none" : "blur(3px)",
        transition: reduced ? "none" : `grid-template-columns 460ms ${MORPH}, opacity ${show ? "320ms" : "160ms"} ${MORPH}, filter 320ms ${MORPH}`,
      }}
    >
      <span className="min-w-0 whitespace-nowrap [clip-path:inset(-4px_-2px)]">{children}</span>
    </span>
  );
}

export function UsageMeter({
  used,
  limit,
  unit = "credits",
  label = "Usage",
  resetAt,
  variant = "bar",
  lowAt = 0.2,
  onUpgrade,
  upgradeLabel = "Upgrade",
  className = "",
  ...props
}: UsageMeterProps) {
  const reduced = useReducedMotion();
  const left = Math.max(0, limit - used);
  const share = limit > 0 ? Math.min(1, used / limit) : 1;
  const out = left <= 0;
  const low = !out && limit > 0 && left / limit <= lowAt;
  const tone = out ? "bg-red-500" : low ? "bg-amber-500" : "bg-foreground";
  const stroke = out ? "stroke-red-500" : low ? "stroke-amber-500" : "stroke-foreground";
  const resets =
    resetAt !== undefined ? new Date(resetAt).toLocaleDateString("en-US", { month: "short", day: "numeric" }) : undefined;
  const summary = out ? `No ${unit} left` : `${number.format(left)} of ${number.format(limit)} ${unit} left`;

  if (variant === "ring") {
    const c = 2 * Math.PI * 8;
    return (
      <div
        role="meter"
        aria-label={label}
        aria-valuemin={0}
        aria-valuemax={limit}
        aria-valuenow={used}
        aria-valuetext={summary}
        title={`${summary}${resets ? ` · resets ${resets}` : ""}`}
        className={`inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground ${className}`}
        {...props}
      >
        <svg aria-hidden="true" viewBox="0 0 20 20" className="size-4 -rotate-90">
          <circle cx="10" cy="10" r="8" fill="none" strokeWidth="2.5" className="stroke-border" />
          <circle
            cx="10"
            cy="10"
            r="8"
            fill="none"
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeDasharray={c}
            strokeDashoffset={c * (1 - share)}
            className={`${stroke} transition-[stroke-dashoffset,stroke] duration-500 motion-reduce:transition-none`}
          />
        </svg>
        <span aria-hidden="true" className={`font-mono tabular-nums transition-colors duration-300 ${out ? "text-red-500" : ""}`}>
          <NumberRoll value={Math.round(share * 100)} suffix="%" duration={700} />
        </span>
      </div>
    );
  }

  return (
    <div className={`w-full ${className}`} {...props}>
      <div className="flex items-baseline justify-between gap-4 text-[12.5px]">
        <span className="font-medium text-foreground">{label}</span>
        {/* The numbers fold away when it runs out; the words that stay glide into "No credits left". */}
        <span aria-hidden="true" className={`flex items-baseline font-mono text-[12px] tabular-nums transition-colors duration-300 ${out ? "text-red-500" : "text-muted-foreground"}`}>
          <Reveal show={!out} reduced={reduced}>
            <span className="inline-flex items-baseline">
              <NumberRoll value={left} format={GROUPED} locales="en-US" duration={700} />
              &nbsp;of&nbsp;
              <NumberRoll value={limit} format={GROUPED} locales="en-US" duration={700} />
              &nbsp;
            </span>
          </Reveal>
          <TextMorph>{out ? `No ${unit} left` : `${unit} left`}</TextMorph>
        </span>
      </div>
      <div
        role="meter"
        aria-label={label}
        aria-valuemin={0}
        aria-valuemax={limit}
        aria-valuenow={used}
        aria-valuetext={summary}
        className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted"
      >
        <div
          className={`h-full rounded-full ${tone} transition-[width,background-color] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none`}
          style={{ width: `${share * 100}%` }}
        />
      </div>
      {(resets || onUpgrade) && (
        <div className="mt-2 flex min-h-[18px] items-center justify-between gap-4 text-[12px] text-muted-foreground">
          <span className="min-w-0">
            <TextMorph>{out ? `You’re out until ${resets ?? "the next reset"}.` : resets ? `Resets ${resets}` : ""}</TextMorph>
          </span>
          {onUpgrade && (
            // Opens in once it matters: when it's low or out.
            <Reveal show={low || out} reduced={reduced} className="shrink-0">
              <span className="block px-0.5">
                <button
                  type="button"
                  inert={!(low || out)}
                  onClick={onUpgrade}
                  className={`rounded font-medium text-foreground underline decoration-border underline-offset-4 hover:decoration-foreground ${FOCUS}`}
                >
                  {upgradeLabel}
                </button>
              </span>
            </Reveal>
          )}
        </div>
      )}
    </div>
  );
}

Registry item: /r/usage-meter.json