"use client";
import * as React from "react";
import { useMotionValueEvent, useReducedMotion, useSpring } from "motion/react";
export interface GaugeThreshold {
/** Zone starts at this value and runs to the next threshold (or max). */
value: number;
color: string;
/** Status text shown while the value is in this zone, e.g. "Normal". */
label: string;
}
type Spring = { stiffness: number; damping: number; mass: number };
export interface RadialGaugeProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
value: number;
/** Accessible name and caption, e.g. "CPU load". */
label: string;
min?: number;
max?: number;
/** Sweep of the dial. */
arc?: "semi" | "three-quarter";
/** Show a needle, a filled value arc, or both. */
indicator?: "needle" | "arc" | "both";
/** Coloured zones with status labels. */
thresholds?: GaugeThreshold[];
/** Number of major intervals (labelled ticks = ticks + 1). */
ticks?: number;
/** Print values next to the major ticks. */
tickLabels?: boolean;
/** Unit shown after the readout, e.g. "%" or "°C". */
unit?: string;
/** Formats the readout, tick labels and aria-valuetext. */
format?: (value: number) => string;
/** Spring that carries the needle from the previous value to the new one. */
spring?: Spring;
/** Needle colour, and the value arc colour when there are no thresholds. */
accent?: string;
/** Maximum width in px (the gauge scales down with its container). */
size?: number;
}
/* -------------------------------- geometry -------------------------------- */
const CX = 100;
const CY = 100;
const R = 76;
const TRACK = 10;
const ARCS = {
semi: { start: -90, end: 90, viewBox: "0 6 200 140", readoutY: 120, height: 140, top: 6 },
"three-quarter": { start: -135, end: 135, viewBox: "0 6 200 172", readoutY: 150, height: 172, top: 6 },
} as const;
/**
* Point at `angle` degrees (0 = up, clockwise) on a circle of radius `r`. Rounded, because trig
* results can differ in the last digit between the server and the browser (hydration mismatch).
*/
function polar(r: number, angle: number) {
const a = (angle * Math.PI) / 180;
const round = (n: number) => Math.round(n * 100) / 100;
return [round(CX + r * Math.sin(a)), round(CY - r * Math.cos(a))] as const;
}
function arcPath(r: number, from: number, to: number) {
const [x0, y0] = polar(r, from);
const [x1, y1] = polar(r, to);
const large = to - from > 180 ? 1 : 0;
return `M${x0.toFixed(2)},${y0.toFixed(2)} A${r},${r} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)}`;
}
const defaultFormat = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
/**
* A semi or three-quarter dial whose needle and value arc spring from the previous value to the
* new one. Threshold zones carry a text status (never colour alone) and the root is a real
* `role="meter"` with value, range and value text.
*/
export function RadialGauge({
value,
label,
min = 0,
max = 100,
arc = "three-quarter",
indicator = "both",
thresholds = [],
ticks = 5,
tickLabels = true,
unit = "",
format,
spring = { stiffness: 120, damping: 16, mass: 1 },
accent = "#FF7448",
size = 280,
className = "",
style,
...props
}: RadialGaugeProps) {
const reduceMotion = useReducedMotion() ?? false;
const needleRef = React.useRef<SVGGElement>(null);
const valueArcRef = React.useRef<SVGPathElement>(null);
const readoutRef = React.useRef<HTMLSpanElement>(null);
const captionId = React.useId();
// Attributes animated imperatively are rendered from the first value only, so React never resets them.
const [initialValue] = React.useState(value);
const geometry = ARCS[arc] ?? ARCS["three-quarter"];
const span = Math.max(1e-9, max - min);
const clampValue = (v: number) => Math.min(max, Math.max(min, v));
const toAngle = (v: number) => geometry.start + ((clampValue(v) - min) / span) * (geometry.end - geometry.start);
const fmt = format ?? ((v: number) => defaultFormat.format(v));
const zones = [...thresholds].sort((a, b) => a.value - b.value);
const zoneAt = (v: number) => {
let zone: GaugeThreshold | undefined;
for (const t of zones) if (v >= t.value) zone = t;
return zone ?? zones[0];
};
const colourAt = (v: number) => zoneAt(v)?.color ?? accent;
const paint = (v: number) => {
const angle = toAngle(v);
needleRef.current?.setAttribute("transform", `rotate(${angle.toFixed(3)} ${CX} ${CY})`);
const arcNode = valueArcRef.current;
if (arcNode) {
const p = (clampValue(v) - min) / span;
arcNode.setAttribute("stroke-dasharray", `${p.toFixed(4)} 2`);
arcNode.setAttribute("stroke", colourAt(v));
}
if (readoutRef.current) readoutRef.current.textContent = fmt(v);
};
const springValue = useSpring(value, spring);
useMotionValueEvent(springValue, "change", paint);
// Spring to every new value (jump with reduced motion), from wherever the needle currently is.
React.useEffect(() => {
if (reduceMotion) springValue.jump(value);
else springValue.set(value);
}, [value, reduceMotion, springValue]);
// Repaint at the current position when geometry or formatting changes.
React.useLayoutEffect(() => {
paint(springValue.get());
});
const zone = zoneAt(value);
const valueText = `${fmt(value)}${unit ? ` ${unit}` : ""}${zone ? `, ${zone.label}` : ""}`;
// Ticks: majors at every interval, minors between them.
const majors = Math.max(1, Math.round(ticks));
const minorPerMajor = majors <= 5 ? 4 : majors <= 8 ? 1 : 0;
const tickMarks: { angle: number; major: boolean; v: number }[] = [];
for (let i = 0; i <= majors; i++) {
const v = min + (span * i) / majors;
tickMarks.push({ angle: toAngle(v), major: true, v });
if (i < majors) {
for (let m = 1; m <= minorPerMajor; m++) {
const mv = v + (span / majors) * (m / (minorPerMajor + 1));
tickMarks.push({ angle: toAngle(mv), major: false, v: mv });
}
}
}
const showNeedle = indicator !== "arc";
const showArc = indicator !== "needle";
const initialAngle = toAngle(initialValue);
const initialP = (clampValue(initialValue) - min) / span;
return (
<div
role="meter"
aria-valuenow={clampValue(value)}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={valueText}
aria-labelledby={captionId}
className={`relative flex w-full flex-col items-center text-zinc-950 dark:text-zinc-50 ${className}`}
style={{ ["--rg-accent" as string]: accent, maxWidth: size, ...style }}
{...props}
>
<div className="@container relative w-full">
<svg viewBox={geometry.viewBox} className="block w-full overflow-visible" aria-hidden="true">
{/* Track */}
<path
d={arcPath(R, geometry.start, geometry.end)}
fill="none"
strokeWidth={TRACK}
className="stroke-zinc-900/[0.07] dark:stroke-white/[0.08]"
/>
{/* Threshold zones, as a thin band outside the track */}
{zones.map((t, i) => {
const from = toAngle(Math.max(min, t.value));
const to = toAngle(i < zones.length - 1 ? zones[i + 1].value : max);
if (to - from < 0.5) return null;
return (
<path
key={`${t.value}-${i}`}
d={arcPath(R + TRACK / 2 + 4, from + 0.6, to - 0.6)}
fill="none"
stroke={t.color}
strokeWidth={2.5}
/>
);
})}
{/* Value arc */}
{showArc ? (
<path
ref={valueArcRef}
d={arcPath(R, geometry.start, geometry.end)}
fill="none"
pathLength={1}
strokeWidth={TRACK}
stroke={colourAt(initialValue)}
strokeDasharray={`${initialP.toFixed(4)} 2`}
/>
) : null}
{/* Ticks */}
{tickMarks.map((t, i) => {
const [x0, y0] = polar(R - TRACK / 2 - 3, t.angle);
const [x1, y1] = polar(R - TRACK / 2 - (t.major ? 9 : 5.5), t.angle);
return (
<line
key={i}
x1={x0}
y1={y0}
x2={x1}
y2={y1}
strokeWidth={t.major ? 1.2 : 0.8}
strokeLinecap="round"
className={t.major ? "stroke-zinc-900/45 dark:stroke-white/45" : "stroke-zinc-900/20 dark:stroke-white/20"}
/>
);
})}
{tickLabels
? tickMarks
.filter((t) => t.major)
.map((t, i) => {
const [x, y] = polar(R - TRACK / 2 - 17, t.angle);
return (
<text
key={i}
x={x}
y={y}
textAnchor="middle"
dominantBaseline="central"
className="fill-zinc-500 font-mono text-[7px] tabular-nums dark:fill-zinc-400"
>
{fmt(t.v)}
</text>
);
})
: null}
{/* Needle */}
{showNeedle ? (
<g ref={needleRef} transform={`rotate(${initialAngle.toFixed(3)} ${CX} ${CY})`}>
<path
d={`M${CX - 1.6},${CY} L${CX},${CY - (R - TRACK / 2 - 12)} L${CX + 1.6},${CY} Z`}
fill="var(--rg-accent)"
/>
</g>
) : null}
{showNeedle ? (
<>
<circle cx={CX} cy={CY} r={5.5} fill="var(--rg-accent)" />
<circle cx={CX} cy={CY} r={2} className="fill-white dark:fill-zinc-950" />
</>
) : null}
</svg>
{/* Readout */}
<div
className="pointer-events-none absolute inset-x-0 flex -translate-y-1/2 flex-col items-center"
style={{ top: `${((geometry.readoutY - geometry.top) / geometry.height) * 100}%` }}
aria-hidden="true"
>
<span className="flex items-baseline gap-1">
<span
ref={readoutRef}
className="text-[clamp(1.5rem,9cqi,2.5rem)] leading-none font-semibold tracking-[-0.04em] tabular-nums"
>
{fmt(initialValue)}
</span>
{unit ? <span className="font-mono text-xs text-zinc-500 dark:text-zinc-400">{unit}</span> : null}
</span>
</div>
</div>
<div className="mt-2 flex flex-col items-center gap-1.5">
{zone ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-zinc-900/10 px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-700 dark:border-white/10 dark:text-zinc-300">
<span className="size-1.5 rounded-full" style={{ backgroundColor: zone.color }} aria-hidden="true" />
{zone.label}
</span>
) : null}
<span id={captionId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</span>
</div>
</div>
);
}