Skip to contentVibraUI
Utilities

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-meter

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
labelReact.ReactNode—What is being metered.
usednumber—How much of the quota has gone.
limitnumber | null—The quota, or null for a plan with none.
unitstring—Appended to the reading, e.g. "requests" or "seats".
format(n: number) => stringwhole numbers with separatorsFormats 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.
resetsAtReact.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.
showPercentbooleanfalseAdds the rounded percentage beneath the bar.

Dependencies

Source

components/ui/usage-meter.tsx
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 }