Spend Meter
Monthly usage by resource against an adjustable limit.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/spend-meter.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport 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; }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); }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
| Prop | Type | Default | Description |
|---|---|---|---|
resources* | UsageResource[] | – | Spend this period by resource. Amounts are integer minor units (cents; sen for IDR). Up to 6. |
budget | number | – | Monthly limit in minor units (controlled). |
defaultBudget | number | 10000 | Starting limit when uncontrolled, in minor units. |
onBudgetChange | (budget: number) => void | – | Called with the new limit when − or + is pressed. |
step | number | 2500 | How much − and + change the limit, in minor units. |
min | number | step | Lowest limit, in minor units. |
max | number | 500000 | Highest limit, in minor units. |
currency | string | "USD" | ISO 4217 currency. |
locale | string | "en-US" | Locale for numbers and money. |
periodLabel | string | – | Period shown under the title, e.g. "November 2026". |
title | string | "Usage" | Card title. |
className | string | – | 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.