Charts

Usage Meter

A horizontal capacity meter for storage, seats, or quota with segmented or continuous fill, threshold color shifts, and an overflow marker.

Install

npx shadcn@latest add @paragon/usage-meter

usage-meter.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const STATE_COLORS = {
  ok: "var(--color-primary)",
  warn: "var(--color-warning)",
  over: "var(--color-destructive)",
} as const;

const STATE_LABEL = {
  ok: "Within limit",
  warn: "Approaching limit",
  over: "Over limit",
} as const;

export interface UsageMeterProps extends React.ComponentProps<"div"> {
  /** What the meter measures, e.g. "Storage". */
  label: string;
  value: number;
  limit: number;
  /** Unit rendered after the numbers, e.g. "GB". */
  unit?: string;
  /** Renders the fill as N discrete segments instead of a continuous bar. */
  segments?: number;
  /** Fraction of the limit where the meter turns to warning. */
  warnAt?: number;
  /** Draw a threshold tick at the warn fraction on the track. */
  showThreshold?: boolean;
  formatValue?: (value: number) => string;
  /** Renders the final state immediately, no fill-in. */
  static?: boolean;
}

/**
 * A horizontal capacity meter for storage, seats, or quota. The fill scales in
 * via transform on first view and retargets on value changes; crossing
 * thresholds shifts the color ok → warn → over with a color transition. Widths,
 * the warn threshold tick, and the over-limit marker are all computed from the
 * value/limit scale — never eyeballed. Hovering or focusing the track opens a
 * tooltip with the exact figures and status. When usage exceeds the limit the
 * bar shows the overage and a marker where the limit sits. Reduced motion
 * renders the final state immediately.
 */
export function UsageMeter({
  label,
  value,
  limit,
  unit,
  segments,
  warnAt = 0.75,
  showThreshold = false,
  formatValue = (v) => v.toLocaleString("en-US"),
  static: isStatic = false,
  className,
  ...props
}: UsageMeterProps) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(containerRef, {
    once: true,
    margin: "0px 0px -24px 0px",
  });
  const [hovered, setHovered] = React.useState(false);

  const animate = !isStatic && !reducedMotion;
  const armed = !animate || inView;

  const fraction = limit > 0 ? value / limit : 0;
  const state: keyof typeof STATE_COLORS =
    fraction > 1 ? "over" : fraction >= warnAt ? "warn" : "ok";
  const color = STATE_COLORS[state];
  // When over the limit, the bar spans the full value and a marker shows
  // where the limit sits inside it.
  const shownFraction = Math.min(fraction, 1);
  const limitMarker = fraction > 1 ? 1 / fraction : null;
  // Warn tick sits at warnAt of the limit; when over-limit the axis rescales.
  const thresholdMarker =
    showThreshold && state !== "over" && warnAt > 0 && warnAt < 1 ? warnAt : null;

  const segmentCount = segments && segments > 0 ? segments : 0;
  const filledSegments = Math.round(shownFraction * segmentCount);

  const pct = Math.round(fraction * 100);
  const valueText = `${formatValue(value)} / ${formatValue(limit)}${unit ? ` ${unit}` : ""}`;

  return (
    <div
      ref={containerRef}
      role="meter"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={limit}
      aria-valuenow={Math.min(value, limit)}
      aria-valuetext={valueText}
      data-slot="usage-meter"
      className={cn("flex w-full flex-col gap-1.5", className)}
      {...props}
    >
      <div className="flex items-baseline justify-between gap-4">
        <span className="text-sm font-medium">{label}</span>
        <span className="text-xs text-muted-foreground tabular-nums">
          <span
            className="font-medium transition-colors duration-300"
            style={{
              color: state === "ok" ? "var(--color-foreground)" : color,
            }}
          >
            {formatValue(value)}
          </span>{" "}
          / {formatValue(limit)}
          {unit ? ` ${unit}` : ""}
          {state === "over" && (
            <span className="ml-1.5" style={{ color }}>
              Over limit
            </span>
          )}
        </span>
      </div>

      <div
        className="relative"
        tabIndex={0}
        onPointerEnter={() => setHovered(true)}
        onPointerLeave={() => setHovered(false)}
        onFocus={() => setHovered(true)}
        onBlur={() => setHovered(false)}
        aria-hidden={false}
      >
        {segmentCount > 0 ? (
          <div className="flex gap-[3px]" aria-hidden>
            {Array.from({ length: segmentCount }, (_, i) => {
              const filled = i < filledSegments;
              return (
                <span
                  key={i}
                  className="h-1.5 flex-1 origin-left rounded-[2px] bg-current/10"
                >
                  <span
                    className="block h-full w-full origin-left rounded-[2px]"
                    style={{
                      background: color,
                      opacity: filled ? 1 : 0,
                      transform: armed && filled ? "scaleX(1)" : "scaleX(0)",
                      transition: animate
                        ? `transform 300ms var(--ease-out) ${i * 15}ms, opacity 150ms var(--ease-out) ${i * 15}ms, background-color 300ms var(--ease-out)`
                        : "background-color 300ms var(--ease-out)",
                    }}
                  />
                </span>
              );
            })}
          </div>
        ) : (
          <div className="relative h-1.5 w-full overflow-hidden rounded-full bg-current/10">
            <span
              className="absolute inset-0 origin-left rounded-full"
              style={{
                background: color,
                transform: `scaleX(${armed ? shownFraction : 0})`,
                transition: animate
                  ? "transform 500ms var(--ease-out), background-color 300ms var(--ease-out)"
                  : "background-color 300ms var(--ease-out)",
              }}
            />
            {/* Over-limit marker: where the limit sits inside the overrun bar */}
            {limitMarker !== null && (
              <span
                className="absolute inset-y-0 w-[2px] bg-background"
                style={{ left: `${limitMarker * 100}%` }}
              />
            )}
          </div>
        )}

        {/* Warn threshold tick — computed at warnAt of the limit */}
        {thresholdMarker !== null && (
          <span
            aria-hidden
            className="absolute -top-0.5 h-2.5 w-px bg-current/40"
            style={{ left: `${thresholdMarker * 100}%` }}
          />
        )}

        {/* Hover / focus tooltip */}
        {hovered && (
          <span
            role="status"
            className="pointer-events-none absolute bottom-full left-0 z-10 mb-2 whitespace-nowrap rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground shadow-overlay tabular-nums"
          >
            <span className="mr-1.5 opacity-70">{STATE_LABEL[state]}</span>
            {valueText} · {pct}%
          </span>
        )}
      </div>
    </div>
  );
}