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.

Report a bug

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.json

Source

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.

Dependencies

  • @vllnt/ui@^0.3.0