Metric Gauge

Real-time arc and dial display for monitored percentages and utilization.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/metric-gauge.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

import * as React from "react"; import { cn } from "../../lib/utils"; import { Badge } from "../badge/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../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 = React.forwardRef<HTMLDivElement, MetricGaugeProps>( ( { className, description, label, max, min = 0, thresholds = DEFAULT_THRESHOLDS, unit, value, ...props }, ref, ) => { 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";

Dependencies

  • @vllnt/ui@^0.2.1