Metric Gauge
Real-time arc and dial display for monitored percentages and utilization. Install Metric Gauge from the VLLNT UI registry with the shadcn CLI.
Real-time arc and dial display for monitored percentages and utilization. Part of the data family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Metric Gauge to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/metric-gauge.jsonSource
import * as React from "react";
import { cn } from "../../lib/utils";
import { Badge } from "../badge/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../card/card";
export type MetricGaugeThreshold = {
colorClassName: string;
label: string;
value: number;
};
export type MetricGaugeProps = React.ComponentPropsWithoutRef<"div"> & {
description?: string;
label: string;
max: number;
min?: number;
thresholds?: MetricGaugeThreshold[];
unit?: string;
value: number;
};
type Point = {
x: number;
y: number;
};
const DEFAULT_THRESHOLDS: MetricGaugeThreshold[] = [
{ colorClassName: "text-emerald-500", label: "Nominal", value: 60 },
{ colorClassName: "text-amber-500", label: "Elevated", value: 85 },
{ colorClassName: "text-destructive", label: "Critical", value: 100 },
];
const GAUGE_CENTER: Point = { x: 100, y: 100 };
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function polarToCartesian(radius: number, angle: number, center: Point): Point {
const radians = ((angle - 90) * Math.PI) / 180;
return {
x: center.x + radius * Math.cos(radians),
y: center.y + radius * Math.sin(radians),
};
}
function describeArc(
radius: number,
angles: { end: number; start: number },
center: Point,
) {
const start = polarToCartesian(radius, angles.end, center);
const end = polarToCartesian(radius, angles.start, center);
const largeArcFlag = angles.end - angles.start <= 180 ? 0 : 1;
return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
}
function formatMetricValue(value: number, unit?: string): string {
const rounded = Number.isInteger(value) ? value.toString() : value.toFixed(1);
return unit ? `${rounded}${unit}` : rounded;
}
function getActiveThreshold(
percent: number,
thresholds: MetricGaugeThreshold[],
) {
return (
thresholds.find((threshold) => percent <= threshold.value) ??
thresholds.at(-1)
);
}
function GaugeDialSvg({
activeThreshold,
endAngle,
label,
}: {
activeThreshold: MetricGaugeThreshold;
endAngle: number;
label: string;
}) {
const gaugePath = describeArc(72, { end: 90, start: -90 }, GAUGE_CENTER);
const activePath = describeArc(
72,
{ end: endAngle, start: -90 },
GAUGE_CENTER,
);
const needlePoint = polarToCartesian(60, endAngle, GAUGE_CENTER);
return (
<svg aria-label={label} className="h-auto w-full" viewBox="0 0 200 128">
<path
d={gaugePath}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeOpacity="0.12"
strokeWidth="14"
/>
<path
className={cn(activeThreshold.colorClassName)}
d={activePath}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="14"
/>
<line
className={cn(activeThreshold.colorClassName)}
stroke="currentColor"
strokeLinecap="round"
strokeWidth="4"
x1={GAUGE_CENTER.x}
x2={needlePoint.x}
y1={GAUGE_CENTER.y}
y2={needlePoint.y}
/>
<circle
className="fill-background stroke-border"
cx={GAUGE_CENTER.x}
cy={GAUGE_CENTER.y}
r="8"
/>
</svg>
);
}
function GaugeDial({
activeThreshold,
endAngle,
label,
max,
min,
percent,
safeValue,
unit,
}: {
activeThreshold: MetricGaugeThreshold;
endAngle: number;
label: string;
max: number;
min: number;
percent: number;
safeValue: number;
unit?: string;
}) {
return (
<>
<div className="relative mx-auto w-full max-w-[280px]">
<GaugeDialSvg
activeThreshold={activeThreshold}
endAngle={endAngle}
label={label}
/>
<div className="absolute inset-x-0 top-12 text-center">
<div className="text-3xl font-semibold tracking-tight">
{formatMetricValue(safeValue, unit)}
</div>
<div className="text-xs text-muted-foreground">
Range {formatMetricValue(min, unit)}–{formatMetricValue(max, unit)}
</div>
</div>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{formatMetricValue(min, unit)}</span>
<span>{Math.round(percent)}%</span>
<span>{formatMetricValue(max, unit)}</span>
</div>
</>
);
}
function GaugeLegend({ thresholds }: { thresholds: MetricGaugeThreshold[] }) {
return (
<div className="flex flex-wrap gap-2">
{thresholds.map((threshold) => (
<div
className="flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs"
key={`${threshold.label}-${threshold.value}`}
>
<span
aria-hidden="true"
className={cn(
"size-2 rounded-full bg-current",
threshold.colorClassName,
)}
/>
<span>{threshold.label}</span>
<span className="text-muted-foreground">≤ {threshold.value}%</span>
</div>
))}
</div>
);
}
export const MetricGauge = ({
className,
description,
label,
max,
min = 0,
ref,
thresholds = DEFAULT_THRESHOLDS,
unit,
value,
...props
}: MetricGaugeProps & { ref?: React.Ref<HTMLDivElement> }) => {
const safeValue = clamp(value, min, max);
const percent = max === min ? 0 : ((safeValue - min) / (max - min)) * 100;
const endAngle = -90 + 180 * (percent / 100);
const activeThreshold = getActiveThreshold(percent, thresholds);
return (
<Card className={cn("shadow-sm", className)} ref={ref} {...props}>
<CardHeader className="space-y-2 pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-base">{label}</CardTitle>
{description ? (
<CardDescription>{description}</CardDescription>
) : null}
</div>
{activeThreshold ? (
<Badge variant="outline">{activeThreshold.label}</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4">
{activeThreshold ? (
<GaugeDial
activeThreshold={activeThreshold}
endAngle={endAngle}
label={label}
max={max}
min={min}
percent={percent}
safeValue={safeValue}
unit={unit}
/>
) : null}
<GaugeLegend thresholds={thresholds} />
</CardContent>
</Card>
);
};
MetricGauge.displayName = "MetricGauge";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.