A semicircular gauge whose arc retargets smoothly on value changes, with threshold color zones and a springing tabular-nums readout.
npx shadcn@latest add @paragon/gaugeAlso installs: number-ticker
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import { cn } from "@/lib/utils";
export interface GaugeZone {
/** Upper bound of the zone, in gauge units. */
to: number;
color: string;
}
const DEFAULT_ZONES = (min: number, max: number): GaugeZone[] => [
{ to: min + (max - min) * 0.6, color: "oklch(0.66 0.14 160)" },
{ to: min + (max - min) * 0.85, color: "oklch(0.76 0.13 85)" },
{ to: max, color: "oklch(0.62 0.18 25)" },
];
export interface GaugeProps extends React.ComponentProps<"div"> {
value: number;
min?: number;
max?: number;
/** Threshold zones, ascending by `to`. The arc adopts the active zone's color. */
zones?: GaugeZone[];
/** Overall width in px. */
size?: number;
thickness?: number;
/** Draw evenly spaced tick marks along the track. */
showTicks?: boolean;
/** Number of tick intervals when `showTicks`. */
tickCount?: number;
/** Caption under the readout. */
label?: string;
/** Unit rendered after the readout. */
unit?: string;
/** Intl.NumberFormat options for the readout. */
formatOptions?: Intl.NumberFormatOptions;
/** Renders the final state immediately, no fill-in. */
static?: boolean;
}
/** Point on the semicircle. t in [0,1] maps left (180°) → right (0°). */
function semiPoint(cx: number, cy: number, r: number, t: number) {
const angle = Math.PI - t * Math.PI;
return [cx + r * Math.cos(angle), cy - r * Math.sin(angle)] as const;
}
/**
* A semicircular gauge with a parametrically drawn 180° arc. The value arc is
* a CSS stroke-dashoffset transition, so value changes retarget mid-flight
* instead of restarting; the readout springs to the new value in tabular-nums.
* Threshold zones tint the track and the arc adopts the active zone's color.
* Optional tick marks are computed on the arc. Hover/focus the arc for a
* cursor-anchored value tooltip. Reduced motion renders the final state.
*/
export function Gauge({
value,
min = 0,
max = 100,
zones,
size = 200,
thickness = 12,
showTicks = false,
tickCount = 10,
label,
unit,
formatOptions,
static: isStatic = false,
className,
...props
}: GaugeProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(containerRef, {
once: true,
margin: "0px 0px -24px 0px",
});
const [hovered, setHovered] = React.useState(false);
const [cursor, setCursor] = React.useState<{ x: number; y: number } | null>(
null,
);
const animate = !isStatic && !reducedMotion;
const armed = !animate || inView;
const resolvedZones = zones ?? DEFAULT_ZONES(min, max);
const span = max - min || 1;
const fraction = Math.min(Math.max((value - min) / span, 0), 1);
const shownFraction = armed ? fraction : 0;
const activeColor =
resolvedZones.find((zone) => value <= zone.to)?.color ??
resolvedZones[resolvedZones.length - 1]?.color ??
"currentColor";
const r = (size - thickness) / 2 - 2;
const cx = size / 2;
const cy = r + thickness / 2 + 2;
const height = cy + 24;
const arc = `M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`;
const format = React.useMemo(
() => new Intl.NumberFormat("en-US", formatOptions),
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(formatOptions)],
);
const ticks = React.useMemo(() => {
if (!showTicks) return [];
return Array.from({ length: tickCount + 1 }, (_, i) => i / tickCount);
}, [showTicks, tickCount]);
const onMove = (e: React.PointerEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
setCursor({ x: e.clientX - rect.left, y: e.clientY - rect.top });
};
let zoneStart = 0;
return (
<div
ref={containerRef}
data-slot="gauge"
className={cn("relative inline-block", className)}
style={{ width: size, height }}
onPointerMove={onMove}
onPointerLeave={() => {
setHovered(false);
setCursor(null);
}}
{...props}
>
<svg
role="img"
aria-label={
label
? `${label}: ${format.format(value)}${unit ? ` ${unit}` : ""} of ${format.format(max)}`
: `Gauge at ${format.format(value)} of ${format.format(max)}`
}
width={size}
height={height}
viewBox={`0 0 ${size} ${height}`}
className="block"
>
{/* Track */}
<path
d={arc}
fill="none"
stroke="currentColor"
strokeOpacity={0.08}
strokeWidth={thickness}
strokeLinecap="round"
/>
{/* Threshold zones, tinted at low opacity */}
{resolvedZones.map((zone, i) => {
const start = zoneStart;
const end = Math.min(Math.max((zone.to - min) / span, 0), 1);
zoneStart = end;
const length = Math.max(end - start, 0);
if (length === 0) return null;
return (
<path
key={i}
d={arc}
fill="none"
stroke={zone.color}
strokeOpacity={0.18}
strokeWidth={thickness}
pathLength={1}
strokeDasharray={`${length} ${1 - length}`}
strokeDashoffset={-start}
/>
);
})}
{/* Tick marks, computed on the arc */}
{ticks.map((t, i) => {
const outer = semiPoint(cx, cy, r + thickness / 2 - 1, t);
const inner = semiPoint(cx, cy, r - thickness / 2 + 1, t);
return (
<line
key={i}
x1={inner[0].toFixed(2)}
y1={inner[1].toFixed(2)}
x2={outer[0].toFixed(2)}
y2={outer[1].toFixed(2)}
stroke="currentColor"
strokeOpacity={0.22}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
);
})}
{/* Value arc — dashoffset transition retargets when value changes */}
<path
d={arc}
fill="none"
stroke={activeColor}
strokeWidth={thickness}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
pathLength={1}
strokeDasharray="1 1"
tabIndex={0}
role="slider"
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-label={label ?? "Gauge value"}
onPointerEnter={() => setHovered(true)}
onFocus={() => setHovered(true)}
onBlur={() => setHovered(false)}
className="cursor-pointer outline-none focus-visible:stroke-foreground"
style={{
strokeDashoffset: 1 - shownFraction,
transition: animate
? "stroke-dashoffset 500ms var(--ease-out), stroke 300ms var(--ease-out)"
: "stroke 300ms var(--ease-out)",
}}
/>
{/* Min / max labels */}
<text
x={cx - r}
y={cy + 16}
textAnchor="middle"
fontSize={10}
className="fill-muted-foreground tabular-nums"
>
{format.format(min)}
</text>
<text
x={cx + r}
y={cy + 16}
textAnchor="middle"
fontSize={10}
className="fill-muted-foreground tabular-nums"
>
{format.format(max)}
</text>
</svg>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 flex flex-col items-center"
style={{ top: cy - 44 }}
>
<span className="flex items-baseline gap-1">
<NumberTicker
value={value}
duration={0.5}
formatOptions={formatOptions}
static={isStatic}
className="text-2xl font-semibold"
/>
{unit && (
<span className="text-sm text-muted-foreground">{unit}</span>
)}
</span>
{label && (
<span className="mt-0.5 text-xs text-muted-foreground">{label}</span>
)}
</div>
{hovered && cursor && !reducedMotion && (
<div
role="status"
className="pointer-events-none absolute z-10 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground shadow-overlay tabular-nums"
style={{
left: cursor.x,
top: cursor.y,
transform: "translate(-50%, calc(-100% - 8px))",
whiteSpace: "nowrap",
}}
>
{format.format(value)}
{unit ? ` ${unit}` : ""}
</div>
)}
</div>
);
}