Usage meter
A quota read out as used against limit, with a bar that colours as the limit approaches.
Server-compatible: no hooks, no client boundary. The tone crosses to warning at 75% of the limit and to danger at 90%, so colour appears only once the number needs acting on; usageTone is exported for anything that has to match. A null limit reads "Unlimited" and drops the bar entirely. The bar is named after the label when the label is a string, and always carries the full reading as aria-valuetext.
Install
$
npx shadcn@latest add @vibra/usage-meterNeeds the @vibra registry in your components.json — set it up once.
Examples
import { formatBytes } from "@/lib/format"
import { UsageMeter } from "@/components/ui/usage-meter"
export default function UsageMeterDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-5 rounded-lg border p-4">
<UsageMeter
label="API requests"
used={38_412}
limit={50_000}
unit="requests"
showPercent
resetsAt="Resets on Oct 1"
/>
<UsageMeter label="Team seats" used={11} limit={12} unit="seats" showPercent />
<UsageMeter
label="Storage"
used={412 * 1024 ** 3}
limit={null}
format={(bytes) => formatBytes(bytes, 0)}
/>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | React.ReactNode | — | What is being metered. |
| used | number | — | How much of the quota has gone. |
| limit | number | null | — | The quota, or null for a plan with none. |
| unit | string | — | Appended to the reading, e.g. "requests" or "seats". |
| format | (n: number) => string | whole numbers with separators | Formats both numbers; pair it with formatBytes for storage. |
| tone | "default" | "success" | "warning" | "danger" | "info" | "auto" | "auto" | auto derives the tone from how much of the limit is gone. |
| resetsAt | React.ReactNode | — | A note under the bar saying when the quota rolls over. |
| size | "sm" | "default" | "default" | sm drops the text to text-xs and thins the bar. |
| showPercent | boolean | false | Adds the rounded percentage beneath the bar. |
Dependencies
Registry
Source
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { clamp, formatNumber, percentOf } from "@/lib/format"
type Tone = "default" | "success" | "warning" | "danger" | "info"
/** The tone a usage bar should take: danger from 90% of the limit, warning from 75%, default below that and whenever the plan is unlimited. */
export function usageTone(used: number, limit: number | null): Tone {
if (limit === null) return "default"
const percent = percentOf(used, limit)
if (percent >= 90) return "danger"
if (percent >= 75) return "warning"
return "default"
}
const usageMeterIndicatorVariants = cva("h-full rounded-full transition-[width] duration-(--duration-slow) ease-(--ease-standard)", {
variants: {
tone: {
// Near-ink until the number starts to matter — colour is reserved for
// the thresholds a reader has to act on.
default: "bg-primary",
success: "bg-success",
warning: "bg-warning",
danger: "bg-danger",
info: "bg-info",
},
},
defaultVariants: { tone: "default" },
})
const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
export type UsageMeterProps = React.ComponentProps<"div"> & {
label: React.ReactNode
used: number
/** The quota, or null for a plan with none — which drops the bar and reads "Unlimited". */
limit: number | null
/** Appended to both numbers' line, e.g. "requests", "GB". */
unit?: string
format?: (n: number) => string
/** "auto" derives the tone from how much of the limit is gone. */
tone?: Tone | "auto"
/** A note under the bar — when the quota rolls over. */
resetsAt?: React.ReactNode
size?: "sm" | "default"
showPercent?: boolean
}
function UsageMeter({
className,
label,
used,
limit,
unit,
format = DEFAULT_FORMAT,
tone = "auto",
resetsAt,
size = "default",
showPercent = false,
...props
}: UsageMeterProps) {
const resolvedTone = tone === "auto" ? usageTone(used, limit) : tone
const suffix = unit ? ` ${unit}` : ""
const percent = limit === null ? 0 : Math.round(clamp(percentOf(used, limit), 0, 100))
const reading =
limit === null
? `${format(used)}${suffix}`
: `${format(used)} / ${format(limit)}${suffix}`
return (
<div
data-slot="usage-meter"
data-tone={resolvedTone}
data-size={size}
className={cn("flex w-full flex-col gap-2", size === "sm" && "gap-1.5", className)}
{...props}
>
<div className="flex items-baseline justify-between gap-3">
<span
data-slot="usage-meter-label"
className={cn("text-sm text-muted-foreground", size === "sm" && "text-xs")}
>
{label}
</span>
<span
data-slot="usage-meter-value"
className={cn(
"font-medium tabular-nums",
size === "sm" ? "text-xs" : "text-sm",
resolvedTone === "warning" && "text-warning",
resolvedTone === "danger" && "text-danger"
)}
>
{reading}
</span>
</div>
{limit === null ? (
<span data-slot="usage-meter-unlimited" className="text-xs text-muted-foreground">
Unlimited
</span>
) : (
<div
data-slot="usage-meter-track"
role="progressbar"
// A ReactNode label cannot become a string, so the bar is named only
// when the label is plain text; the reading covers the rest.
aria-label={typeof label === "string" ? label : undefined}
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={reading}
className={cn(
"w-full overflow-hidden rounded-full bg-muted",
size === "sm" ? "h-1" : "h-1.5"
)}
>
<div
data-slot="usage-meter-indicator"
className={usageMeterIndicatorVariants({ tone: resolvedTone })}
style={{ width: `${percent}%` }}
/>
</div>
)}
{showPercent || resetsAt ? (
<div
data-slot="usage-meter-footer"
className="flex items-baseline justify-between gap-3 text-xs text-muted-foreground"
>
{resetsAt ? <span data-slot="usage-meter-resets">{resetsAt}</span> : <span />}
{showPercent && limit !== null ? (
<span data-slot="usage-meter-percent" className="tabular-nums">{`${percent}%`}</span>
) : null}
</div>
) : null}
</div>
)
}
export { UsageMeter, usageMeterIndicatorVariants }