A horizontal capacity meter for storage, seats, or quota with segmented or continuous fill, threshold color shifts, and an overflow marker.
npx shadcn@latest add @paragon/usage-meter"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>
);
}