Gauge
NewInstallation
npx shadcn@latest add https://uibrik.com/r/gauge.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/gauge/gauge.tsx"use client"; import { useEffect, useState, type FocusEvent } from "react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"; import { cn } from "@/lib/utils"; import { ChartCard, DataTable, Headline, Tooltip, useWidth } from "../_charts/parts"; import { clamp } from "../_charts/scale"; import { accentVars, SETTLE, seriesColor, STATUS, TRACK } from "../_charts/theme"; import { formatShare, formatValue, type ValueFormat } from "../_charts/format"; // ─── Geometry ───────────────────────────────────────────────────────────────── // A 240° arc rather than a flat semicircle: the readout sits at the circle's // centre instead of being squeezed onto the baseline, the longer arc reads // finer, and the open ends leave room for the min and max labels. const SWEEP = 240; const START = -SWEEP / 2; const R = 96; const STROKE = 12; const VB_W = 232; const CX = VB_W / 2; const CY = 114; const VB_H = 196; // Zone ticks sit just outside the track. const TICK_IN = R + STROKE / 2 + 3; const TICK_OUT = TICK_IN + 5; /** Point at `deg` (clockwise from 12 o'clock) on a circle of radius `r`. */ const polar = (r: number, deg: number) => { const a = (deg * Math.PI) / 180; // Rounded so the server and the browser print the same coordinates. const round = (v: number) => Math.round(v * 100) / 100; return [round(CX + r * Math.sin(a)), round(CY - r * Math.cos(a))] as const; }; /** Arc from `a0` to `a1` degrees, clockwise. */ function arc(r: number, a0: number, a1: number) { const [x0, y0] = polar(r, a0); const [x1, y1] = polar(r, a1); return `M${x0} ${y0}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1} ${y1}`; } const angle = (fraction: number) => START + SWEEP * fraction; // ─── Zones ──────────────────────────────────────────────────────────────────── export type GaugeStatus = "good" | "warning" | "critical"; export type GaugeThreshold = { at: number; status: GaugeStatus }; const LABELS: Record<GaugeStatus, string> = { good: "Healthy", warning: "Getting close", critical: "Over limit" }; // How the distance to a zone reads in the tooltip: "12 GB until limit". const NOUN: Record<GaugeStatus, string> = { good: "healthy", warning: "warning", critical: "limit" }; const DEFAULT_THRESHOLDS: GaugeThreshold[] = [ { at: 70, status: "warning" }, { at: 90, status: "critical" }, ]; export type GaugeProps = { /** Names the metric. */ title?: string; /** Current reading. */ value?: number; /** Start of the range. */ min?: number; /** End of the range. */ max?: number; /** Where each zone begins, in value units. Below the first is good. Empty uses one colour. */ thresholds?: GaugeThreshold[]; /** Words for each zone, shown beside its dot. */ labels?: Partial<Record<GaugeStatus, string>>; /** Intl.NumberFormat options for every number, e.g. { style: "percent" }. */ format?: ValueFormat; /** Arc colour when there are no thresholds. Empty uses the chart palette. */ accent?: string; /** Note under the gauge, e.g. "of 100 GB plan". */ caption?: string; className?: string; }; export function Gauge({ title = "Storage", value = 64, min = 0, max = 100, thresholds = DEFAULT_THRESHOLDS, labels, format = { style: "unit", unit: "gigabyte", maximumFractionDigits: 0 }, accent = "", caption = "of 100 GB plan", className, }: GaugeProps) { const reduce = useReducedMotion(); const [ref, width] = useWidth(); const [hover, setHover] = useState(false); const [focus, setFocus] = useState(false); const fraction = clamp(max > min ? (value - min) / (max - min) : 0, 0, 1); const zones = [...thresholds].sort((a, b) => a.at - b.at); const zoned = zones.length > 0; // The zone the value is in: the last threshold it has reached. const current = zones.filter((t) => value >= t.at).pop(); const status: GaugeStatus = current?.status ?? "good"; const next = zones.find((t) => t.at > value); const color = zoned ? STATUS[status] : seriesColor(0); const fmt = (v: number) => formatValue(v, format); const label = zoned ? (labels?.[status] ?? LABELS[status]) : `${formatShare(fraction)} used`; const distance = !zoned ? undefined : next ? `${fmt(next.at - value)} until ${NOUN[next.status]}` : current ? `${fmt(value - current.at)} past ${NOUN[current.status]}` : undefined; // The fill sweeps in from min on mount, then settles onto each new value. const progress = useMotionValue(reduce ? fraction : 0); useEffect(() => { if (reduce) { progress.set(fraction); return; } const controls = animate(progress, fraction, SETTLE); return () => controls.stop(); }, [fraction, reduce, progress]); // Below a sliver, draw nothing: a round cap on a zero-length arc is a dot. const fill = useTransform(progress, (f) => (f < 0.002 ? "" : arc(R, START, angle(f)))); // The tooltip follows the end of the fill across, but always hangs above // the arc's crown so it never covers the readout. const svgW = Math.min(width, VB_W); const scale = svgW / VB_W; const [ex] = polar(R, angle(fraction)); const tipX = (width - svgW) / 2 + ex * scale; const tipY = (CY - TICK_OUT) * scale; const [minX, endY] = polar(R, START); const [maxX] = polar(R, START + SWEEP); const valueText = `${fmt(value)}${zoned ? `, ${label}` : ""}`; return ( <ChartCard title={title} footer={caption ? <p className="text-xs text-muted-foreground">{caption}</p> : undefined} className={cn("max-w-80", className)} style={accentVars(accent)} > <div ref={ref} role="meter" tabIndex={0} aria-label={`${title}: ${valueText}${caption ? ` ${caption}` : ""}`} aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} aria-valuetext={valueText} className="relative touch-none rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-foreground/20" onPointerEnter={() => setHover(true)} onPointerDown={() => setHover(true)} onPointerLeave={() => setHover(false)} onFocus={(e: FocusEvent<HTMLDivElement>) => setFocus(e.currentTarget.matches(":focus-visible"))} onBlur={() => setFocus(false)} > <div className="relative mx-auto w-full max-w-[232px]"> <svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="block w-full overflow-visible" aria-hidden> <path d={arc(R, START, START + SWEEP)} fill="none" stroke={TRACK} strokeWidth={STROKE} strokeLinecap="round" /> {/* Zone boundaries, marked beside the track rather than painted on it. */} {zones .filter((t) => t.at > min && t.at < max) .map((t) => { const a = angle((t.at - min) / (max - min)); const [x0, y0] = polar(TICK_IN, a); const [x1, y1] = polar(TICK_OUT, a); return <line key={t.at} x1={x0} y1={y0} x2={x1} y2={y1} className="stroke-foreground/25" strokeWidth={1.5} strokeLinecap="round" />; })} <motion.path d={fill} fill="none" strokeWidth={STROKE} strokeLinecap="round" className="transition-[stroke] duration-300" style={{ stroke: color }} /> <text x={minX} y={endY + STROKE / 2 + 18} textAnchor="middle" className="fill-muted-foreground text-[11px] font-medium tabular-nums"> {fmt(min)} </text> <text x={maxX} y={endY + STROKE / 2 + 18} textAnchor="middle" className="fill-muted-foreground text-[11px] font-medium tabular-nums"> {fmt(max)} </text> </svg> {/* Readout, centred in the arc. */} <div className="absolute left-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1 whitespace-nowrap" style={{ top: `${((CY + 6) / VB_H) * 100}%` }} > <Headline value={value} format={format} /> <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <span aria-hidden className="size-2 rounded-full transition-colors duration-300" style={{ background: color }} /> <AnimatePresence mode="wait" initial={false}> <motion.span key={label} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: reduce ? 0 : 0.12 }} > {label} </motion.span> </AnimatePresence> </span> </div> </div> <Tooltip x={tipX} y={tipY} bounds={width} visible={(hover || focus) && width > 0}> <div> <span className="tabular-nums">{fmt(value)}</span> <span className="opacity-60"> · {label}</span> </div> {distance && <div className="mt-0.5 opacity-60">{distance}</div>} </Tooltip> </div> <DataTable caption={title} columns={["Reading", title]} rows={[ ["Value", valueText], ["Min", fmt(min)], ["Max", fmt(max)], ...zones.map((t) => [`${labels?.[t.status] ?? LABELS[t.status]} from`, fmt(t.at)]), ]} /> </ChartCard> ); }
Adds the files to
components/uibrik/gauge and installs @number-flow/react, @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/gauge.Props
Gauge- title
- Type string
- "Storage"
- Names the metric.
- value
- Type number
- 64
- Current reading.
- format
- Type Intl.NumberFormatOptions
- gigabytes
- Formats every number, e.g. { style: "percent" }.
- accent
- Type string
- ""
- Arc colour without zones. Empty uses the chart palette.
- min
- Type number
- 0
- Start of the range.
- max
- Type number
- 100
- End of the range.
- thresholds
- Type { at: number; status: "good" | "warning" | "critical" }[]
- warning 70, critical 90
- Where each zone begins. Below the first is good. Empty uses one colour.
- labels
- Type Partial<Record<"good" | "warning" | "critical", string>>
- –
- Words for each zone, shown beside its dot.
- caption
- Type string
- "of 100 GB plan"
- Note under the gauge.
- className
- Type string
- –
- Classes for the outer shell.