Skip to content

Spend Meter

Monthly usage by resource against an adjustable limit.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/spend-meter.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
    lib/usage/colors.ts
    /**
     * One stable color per usage category, in this order (max 6). Colors are always paired with a
     * label or legend, never used alone. `--chart-6` falls back to violet for themes without it.
     */
    export const CATEGORY_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6, #7c3aed)"] as const;
    
    /** Color for the category at `index` in the category list. Indexes past 5 share the last color. */
    export const categoryColor = (index: number) => CATEGORY_COLORS[Math.max(0, Math.min(CATEGORY_COLORS.length - 1, index))];
    lib/usage/format.ts
    /**
     * Money formatting for usage and billing. Amounts are integer minor units (cents; sen for IDR).
     * IDR is shown without decimals. Locales are fixed by the caller, never read from the browser,
     * so server and client output match.
     */
    const NO_DECIMALS = new Set(["IDR", "JPY", "KRW", "VND"]);
    
    export function moneyFormat(currency = "USD", locale = "en-US") {
      const decimals = NO_DECIMALS.has(currency) ? 0 : 2;
      const options = { style: "currency", currency, minimumFractionDigits: decimals, maximumFractionDigits: decimals } as const;
      const full = new Intl.NumberFormat(locale, options);
      const compact = new Intl.NumberFormat(locale, { style: "currency", currency, notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 });
      return {
        currency,
        locale,
        decimals,
        /** "$55.80", "Rp 892.800". */
        format: (minor: number) => full.format(minor / 100),
        /** "$1.2K", "Rp 1,2 jt". */
        compact: (minor: number) => compact.format(minor / 100),
        /** Props for RollingDigits so totals roll instead of swapping. */
        rolling: (minor: number) => ({ value: minor / 100, decimals, format: options, locale }),
      };
    }
    
    export type MoneyFormat = ReturnType<typeof moneyFormat>;
    
    /** "427,200" in the given locale. */
    export const formatCredits = (credits: number, locale = "en-US") => new Intl.NumberFormat(locale).format(Math.round(credits));
    
    /** Compact credits: "12.4K". */
    export const formatCreditsCompact = (credits: number, locale = "en-US") => new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 }).format(credits);
    
    /** A ratio as a percent: 0.558 → "56%". */
    export const formatShare = (ratio: number, digits = 0) => `${(ratio * 100).toFixed(digits)}%`;
    
    /** A fixed, locale-stable date in UTC: "Nov 15" or "Nov 15, 2026". */
    export function formatDay(iso: string, withYear = false, locale = "en-US") {
      return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", ...(withYear ? { year: "numeric" } : {}), timeZone: "UTC" }).format(new Date(`${iso}T00:00:00Z`));
    }
    lib/usage/types.ts
    /** Money is integer minor units (cents, or sen for IDR); credits are integers. */
    
    export interface UsageCategory {
      id: string;
      name: string;
    }
    
    export interface UsageResource {
      id: string;
      name: string;
      /** Amount spent this period, in minor units (cents). */
      amount: number;
      /** A detail line, e.g. "1.8M tokens across 3 models". */
      detail?: string;
    }
    
    export interface DailyUsage {
      /** ISO date, "2026-10-15". */
      date: string;
      /** Credits per category id. Days after today are left out of `values` (an empty object). */
      values: Record<string, number>;
    }
    
    export interface UsageWorkflow {
      id: string;
      name: string;
      /** A subtitle such as the trigger ("On new ticket"). */
      subtitle?: string;
      /** Category id. */
      category: string;
      runs: number;
      /** Hours of work returned. */
      hours: number;
      credits: number;
      /** Credits in the previous cycle, for the change column. */
      previous?: number;
      /** Recent daily credits, for the sparkline. */
      trend?: number[];
    }
    
    export interface UsageTeammate {
      id: string;
      name: string;
      role: string;
      /** Category the teammate spends most on. */
      category: string;
      runs: number;
      hours: number;
      credits: number;
    }
  3. Add the theme tokens

    This component uses --warning, which shadcn does not define by default. Add it to your global CSS:

    app/globals.css
    :root {
      --warning: #b45309;
    }
    
    .dark {
      --warning: #fbbf24;
    }
    
    @theme inline {
      --color-warning: var(--warning);
    }
  4. Copy the source

    components/ui/spend-meter/spend-meter.tsx
    "use client";
    
    import { useId, useState } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { Minus, Plus } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, ease, spring, stagger } from "@/lib/motion";
    import { RollingDigits } from "@/components/ui/rolling-digits/rolling-digits";
    import { categoryColor } from "@/lib/usage/colors";
    import { formatShare, moneyFormat } from "@/lib/usage/format";
    import type { UsageResource } from "@/lib/usage/types";
    
    export interface SpendMeterProps {
      /** Spend this period by resource. Amounts are integer minor units (cents; sen for IDR). Up to 6. */
      resources: UsageResource[];
      /** Monthly limit in minor units (controlled). */
      budget?: number;
      /** Starting limit when uncontrolled, in minor units. @default 10000 */
      defaultBudget?: number;
      /** Called with the new limit when − or + is pressed. */
      onBudgetChange?: (budget: number) => void;
      /** How much − and + change the limit, in minor units. @default 2500 */
      step?: number;
      /** Lowest limit, in minor units. @default step */
      min?: number;
      /** Highest limit, in minor units. @default 500000 */
      max?: number;
      /** ISO 4217 currency. @default "USD" */
      currency?: string;
      /** Locale for numbers and money. @default "en-US" */
      locale?: string;
      /** Period shown under the title, e.g. "November 2026". */
      periodLabel?: string;
      /** Card title. @default "Usage" */
      title?: string;
      /** Classes for the card. */
      className?: string;
    }
    
    type Level = "ok" | "warning" | "danger";
    
    const LEVEL_TEXT: Record<Level, string> = {
      ok: "text-muted-foreground",
      warning: "text-[color-mix(in_oklab,var(--warning)_70%,var(--foreground))]",
      danger: "text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]",
    };
    
    /** clip-path that shows the slice of a full-width bar between `from` and `to` (0–1). */
    const slice = (from: number, to: number, gap = 0) =>
      `inset(0% ${(100 - Math.min(1, Math.max(0, to)) * 100 + (to < 1 ? gap : 0)).toFixed(3)}% 0% ${(Math.min(1, Math.max(0, from)) * 100).toFixed(3)}% round 999px)`;
    
    /**
     * A compact monthly usage card for AI and API products: the total against a limit with rolling
     * digits, a segmented meter by resource, linked resource rows and a − / + limit stepper. Turns amber
     * from 75% and red at the limit, where the overflow is hatched.
     */
    export function SpendMeter({
      resources,
      budget: budgetProp,
      defaultBudget = 10000,
      onBudgetChange,
      step = 2500,
      min = step,
      max = 500000,
      currency = "USD",
      locale = "en-US",
      periodLabel,
      title = "Usage",
      className,
    }: SpendMeterProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const [inner, setInner] = useState(defaultBudget);
      const budget = budgetProp ?? inner;
      const [active, setActive] = useState<string | null>(null);
      const [announce, setAnnounce] = useState("");
      const money = moneyFormat(currency, locale);
    
      const total = resources.reduce((sum, r) => sum + r.amount, 0);
      const ratio = budget > 0 ? total / budget : 1;
      const level: Level = ratio >= 1 ? "danger" : ratio >= 0.75 ? "warning" : "ok";
      // The meter spans the limit, or the total once spend runs past it.
      const scale = Math.max(budget, total, 1);
      const limitAt = budget / scale;
    
      const setBudget = (next: number) => {
        const clamped = Math.min(max, Math.max(min, next));
        if (clamped === budget) return;
        if (budgetProp === undefined) setInner(clamped);
        onBudgetChange?.(clamped);
        setAnnounce(`Monthly limit ${money.format(clamped)}`);
      };
    
      const shown = resources.slice(0, 6);
      const segments = shown.map((resource, index) => {
        const before = shown.slice(0, index).reduce((sum, r) => sum + r.amount, 0);
        return { resource, index, from: before / scale, to: (before + resource.amount) / scale };
      });
    
      const segmentTransition = (index: number) =>
        reduceMotion ? { duration: 0 } : { ...spring.smooth, delay: index * stagger.loose };
    
      return (
        <section aria-labelledby={`${id}-title`} className={cn("w-full max-w-md rounded-2xl border border-border bg-background p-5", className)}>
          <header className="flex items-start justify-between gap-3">
            <div>
              <h3 id={`${id}-title`} className="text-sm font-semibold text-foreground">
                {title}
              </h3>
              {periodLabel ? <p className="mt-0.5 text-[12px] text-muted-foreground">{periodLabel}</p> : null}
            </div>
            <AnimatePresence initial={false} mode="popLayout">
              {level !== "ok" ? (
                <motion.span
                  key={level}
                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{ opacity: 0 }}
                  transition={{ duration: duration.quick, ease: ease.out }}
                  className={cn(
                    "inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium",
                    level === "warning" ? "bg-warning/12" : "bg-destructive/10",
                    LEVEL_TEXT[level],
                  )}
                >
                  <span aria-hidden="true" className={cn("size-1.5 rounded-full", level === "warning" ? "bg-warning" : "bg-destructive")} />
                  {level === "warning" ? "Approaching limit" : "Limit reached"}
                </motion.span>
              ) : null}
            </AnimatePresence>
          </header>
    
          <p className="mt-4 text-[12px] text-muted-foreground">Total used</p>
          <p className="mt-0.5 flex items-baseline gap-1.5 font-mono tracking-tight text-foreground">
            <RollingDigits {...money.rolling(total)} animateOnMount={false} className="text-3xl font-semibold" />
            <span className="text-base text-muted-foreground">
              / <RollingDigits {...money.rolling(budget)} animateOnMount={false} />
            </span>
          </p>
          <p className={cn("mt-1 text-[13px] tabular-nums transition-colors", LEVEL_TEXT[level])}>
            {level === "danger" ? (
              <>
                {total > budget ? `${money.format(total - budget)} over the limit` : "The monthly limit is used up"} · {formatShare(ratio)} used
              </>
            ) : (
              <>
                {money.format(budget - total)} remaining this month · {formatShare(ratio)} used
              </>
            )}
          </p>
    
          {/* Segmented meter. Each segment is a full-width bar clipped to its slice, so it animates clip-path only. */}
          <div
            role="meter"
            aria-label={`${title} against the monthly limit`}
            aria-valuemin={0}
            aria-valuemax={budget}
            aria-valuenow={Math.min(total, budget)}
            aria-valuetext={`${money.format(total)} of ${money.format(budget)} used, ${formatShare(ratio)}`}
            className="relative mt-4 h-3 w-full rounded-full bg-surface-2"
            onMouseLeave={() => setActive(null)}
          >
            {segments.map(({ resource, index, from, to }) => (
              <motion.span
                key={resource.id}
                aria-hidden="true"
                className="absolute inset-0 transition-opacity duration-200"
                style={{ background: categoryColor(index), opacity: active && active !== resource.id ? 0.3 : 1 }}
                initial={reduceMotion ? false : { clipPath: slice(from, from) }}
                animate={{ clipPath: slice(from, to, 0.6) }}
                transition={segmentTransition(index)}
                onMouseEnter={() => setActive(resource.id)}
              />
            ))}
            {total > budget ? (
              <motion.span
                aria-hidden="true"
                className="pointer-events-none absolute inset-0"
                style={{ background: "repeating-linear-gradient(135deg, color-mix(in oklab, var(--destructive) 70%, transparent) 0 3px, transparent 3px 6px)" }}
                initial={reduceMotion ? false : { clipPath: slice(limitAt, limitAt) }}
                animate={{ clipPath: slice(limitAt, 1) }}
                transition={segmentTransition(segments.length)}
              />
            ) : null}
            {/* The limit mark, once spend has passed it. */}
            {/* A full-width layer moved by a share of its own width, so the mark travels on transform only. */}
            <motion.span
              aria-hidden="true"
              className="pointer-events-none absolute -inset-y-1 inset-x-0"
              initial={false}
              animate={{ x: `${(limitAt * 100).toFixed(3)}%`, opacity: total > budget ? 1 : 0 }}
              transition={reduceMotion ? { duration: 0 } : spring.smooth}
            >
              <span className="absolute inset-y-0 -left-px w-0.5 rounded-full bg-foreground" />
            </motion.span>
          </div>
    
          <ul className="mt-4 space-y-1" onMouseLeave={() => setActive(null)}>
            {segments.map(({ resource, index }) => (
              <li
                key={resource.id}
                onMouseEnter={() => setActive(resource.id)}
                className={cn(
                  "-mx-2 flex items-center gap-3 rounded-lg px-2 py-2 transition-[background-color,opacity] duration-200",
                  active === resource.id && "bg-surface-2",
                  active && active !== resource.id && "opacity-55",
                )}
              >
                <span aria-hidden="true" className="size-2.5 shrink-0 rounded-[3px]" style={{ background: categoryColor(index) }} />
                <div className="min-w-0 flex-1">
                  <p className="truncate text-[13px] font-medium text-foreground">{resource.name}</p>
                  {resource.detail ? <p className="truncate text-[12px] text-muted-foreground">{resource.detail}</p> : null}
                </div>
                <span className="font-mono text-[13px] text-foreground tabular-nums">{money.format(resource.amount)}</span>
              </li>
            ))}
          </ul>
    
          <div role="group" aria-labelledby={`${id}-limit`} className="mt-4 flex items-center justify-between gap-3 border-t border-border pt-4">
            <span id={`${id}-limit`} className="text-[13px] text-muted-foreground">
              Monthly limit
            </span>
            <div className="flex items-center gap-1">
              <button
                type="button"
                aria-label={`Lower the limit by ${money.format(step)}`}
                disabled={budget <= min}
                onClick={() => setBudget(budget - step)}
                className="relative after:absolute after:-inset-0.5 grid size-9 cursor-pointer place-items-center rounded-full border border-border text-foreground outline-none transition-colors hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
              >
                <Minus className="size-4" aria-hidden="true" />
              </button>
              <span className="min-w-[5.5rem] text-center font-mono text-sm font-semibold text-foreground">
                <RollingDigits {...money.rolling(budget)} animateOnMount={false} />
              </span>
              <button
                type="button"
                aria-label={`Raise the limit by ${money.format(step)}`}
                disabled={budget >= max}
                onClick={() => setBudget(budget + step)}
                className="relative after:absolute after:-inset-0.5 grid size-9 cursor-pointer place-items-center rounded-full border border-border text-foreground outline-none transition-colors hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
              >
                <Plus className="size-4" aria-hidden="true" />
              </button>
            </div>
          </div>
          <p aria-live="polite" className="sr-only">
            {announce}
          </p>
        </section>
      );
    }

Usage

import { SpendMeter } from "@/components/ui/spend-meter/spend-meter";

<SpendMeter
  resources={[
    { id: "inference", name: "Model inference", amount: 3124, detail: "1.8M tokens across 3 models" },
    { id: "tools", name: "Tool calls", amount: 1606, detail: "4,212 calls to 18 tools" },
    { id: "storage", name: "Knowledge storage", amount: 850 },
  ]} // cents
  defaultBudget={10000} // $100
  onBudgetChange={(budget) => saveLimit(budget)}
  periodLabel="November 2026"
/>

API reference

PropTypeDefaultDescription
resources*UsageResource[]–Spend this period by resource. Amounts are integer minor units (cents; sen for IDR). Up to 6.
budgetnumber–Monthly limit in minor units (controlled).
defaultBudgetnumber10000Starting limit when uncontrolled, in minor units.
onBudgetChange(budget: number) => void–Called with the new limit when − or + is pressed.
stepnumber2500How much − and + change the limit, in minor units.
minnumberstepLowest limit, in minor units.
maxnumber500000Highest limit, in minor units.
currencystring"USD"ISO 4217 currency.
localestring"en-US"Locale for numbers and money.
periodLabelstring–Period shown under the title, e.g. "November 2026".
titlestring"Usage"Card title.
classNamestring–Classes for the card.
  • All money is integer minor units (cents, or sen for IDR) and is only divided by 100 for display. `step`, `min` and `max` use the same units, so the default step of 2500 is $25.
  • Under 75% of the limit the card is neutral, from 75% it turns amber, and at 100% it turns red; spend past the limit rescales the meter and hatches the overflow.
  • Segment colors come from the shared category tokens (--chart-1 to --chart-6), in the order the resources are passed.

Accessibility

  • The meter is a role="meter" with the total, the limit and the percentage in its value text. Every segment has a matching row with its name and amount, so color is never the only cue.
  • The limit stepper is a labelled group of two buttons; each change is announced politely, and the buttons disable at the minimum and maximum.
  • "Approaching limit" and "Limit reached" are written out, not only shown in amber or red. With reduced motion, segments and numbers change without travel.