Gauge
A radial sweep gauge whose arc springs to its value with a calibrated overshoot-and-settle while the center count ticks up in lockstep. Continuous, segmented-tick, and half-speedometer variants; threshold crossings shift the hue over 300ms instead of snapping.
motionfree
"use client";
import { motion, useInView, useMotionValueEvent, useSpring, useTransform, type MotionValue } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
/** A threshold stop: at or past `value`, the arc shifts to `color` (over ~300ms). */
export interface GaugeThreshold {
/** Value (in the same units as `value`/`max`) at which this band begins. */
value: number;
/** Arc color while the animated value sits in this band. */
color: string;
}
export interface GaugeColors {
/** Unlit track / unlit segment color. @default "#27272a" */
track?: string;
/** Value arc color below the first threshold. @default "#93c5fd" */
value?: string;
/** Center count-up text color. @default "#fafafa" */
text?: string;
}
export interface GaugeProps extends React.ComponentPropsWithoutRef<"div"> {
/** Current value. Clamped to [0, max] for the sweep and aria semantics. */
value: number;
/** Value at which the gauge reads full. @default 100 */
max?: number;
/** Arc style: continuous ring, discrete igniting ticks, or 180° speedometer. @default "arc" */
variant?: "arc" | "segments" | "half";
/** Outer diameter of the gauge in px (half variant is `size` wide, half as tall). @default 200 */
size?: number;
/** Stroke thickness of the arc in px. @default 10 */
thickness?: number;
/** Opening at the bottom of the ring in degrees (arc/segments variants; half is fixed at 180°). @default 80 */
gapAngle?: number;
/** Number of ticks in the segments variant. @default 24 */
segmentCount?: number;
/** Spring stiffness — higher sweeps faster. @default 80 */
stiffness?: number;
/** Spring damping — lower overshoots further past the mark before settling. @default 14 */
damping?: number;
/** Render the auto count-up value in the center (ignored when `children` is set). @default true */
showValue?: boolean;
/** Formats the center count-up (and aria-valuetext). @default v => String(Math.round(v)) */
formatValue?: (value: number) => string;
/** Custom center content — replaces the auto count-up entirely. */
children?: React.ReactNode;
/** Track / value-arc / center-text colors. */
colors?: GaugeColors;
/** Ascending color stops; the arc's hue shifts over ~300ms as the sweep crosses each. */
thresholds?: GaugeThreshold[];
/** Draw the hairline tick ring inside the arc (arc/half variants). @default true */
ticks?: boolean;
/** When the first sweep runs: on scroll into view, or immediately on mount. @default "in-view" */
trigger?: "in-view" | "mount";
/** Freezes the needle mid-flight; unpausing resumes toward the target. @default false */
paused?: boolean;
/** Accessible name for the meter. @default "Gauge" */
label?: string;
}
const DEFAULT_TRACK = "#27272a";
const DEFAULT_VALUE = "#93c5fd";
const DEFAULT_TEXT = "#fafafa";
const TICK_COLOR = "#52525b";
const FLASH_COLOR = "#f8fafc";
/** Point on a circle where 0° is 12 o'clock and angles grow clockwise. */
function polarPoint(cx: number, cy: number, r: number, angle: number): [number, number] {
const rad = (angle * Math.PI) / 180;
return [round2(cx + r * Math.sin(rad)), round2(cy - r * Math.cos(rad))];
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function arcPath(cx: number, cy: number, r: number, startAngle: number, sweep: number): string {
const s = Math.min(Math.max(sweep, 0.01), 359.98);
const [x1, y1] = polarPoint(cx, cy, r, startAngle);
const [x2, y2] = polarPoint(cx, cy, r, startAngle + s);
const large = s > 180 ? 1 : 0;
return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`;
}
function clamp(n: number, min: number, max: number): number {
return Math.min(Math.max(n, min), max);
}
/** One tick of the segments variant. Ignites with a 1-frame bright flash that
* cools to the active band color; unlit ticks rest at the track color. Split
* out so each tick owns its lit state — the parent never re-renders per frame. */
function GaugeSegment({
d,
thickness,
threshold,
progress,
litColor,
trackColor,
reducedMotion,
}: {
d: string;
thickness: number;
threshold: number;
progress: MotionValue<number>;
litColor: string;
trackColor: string;
reducedMotion: boolean;
}) {
const [lit, setLit] = React.useState(false);
useMotionValueEvent(progress, "change", (p) => setLit(p >= threshold));
// Sync on mount / threshold change (reduced-motion jump can precede subscription).
React.useEffect(() => {
setLit(progress.get() >= threshold);
}, [progress, threshold]);
return (
<g>
<motion.path
d={d}
fill="none"
strokeWidth={thickness}
strokeLinecap="butt"
initial={false}
animate={{ stroke: lit ? litColor : trackColor }}
transition={{ duration: reducedMotion ? 0 : 0.3, ease: "easeOut" }}
/>
<SegmentFlash d={d} thickness={thickness} lit={lit} reducedMotion={reducedMotion} />
</g>
);
}
/** The ignition flash overlay, memoized so band-color re-renders never restart
* the flash keyframes — it only re-runs when `lit` actually flips. */
const SegmentFlash = React.memo(function SegmentFlash({
d,
thickness,
lit,
reducedMotion,
}: {
d: string;
thickness: number;
lit: boolean;
reducedMotion: boolean;
}) {
return (
<motion.path
d={d}
fill="none"
strokeWidth={thickness}
strokeLinecap="butt"
stroke={FLASH_COLOR}
initial={false}
animate={{ opacity: lit && !reducedMotion ? [0.95, 0] : 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
/>
);
});
/**
* Gauge — a radial sweep gauge off a machinist's bench: matte graphite track,
* hairline tick ring, one cool accent arc. The arc springs to its value with a
* calibrated overshoot-and-settle — mass and damping, not bounce — while the
* center count ticks up in lockstep with the needle. Value changes re-sweep
* from the current position, never from zero. Variants: continuous arc,
* segmented ticks that ignite sequentially (each with a bright flash that
* cools to steady state), and a 180° half-speedometer. Threshold crossings
* shift the arc's hue over ~300ms rather than snapping. `role="meter"` with
* real aria value semantics; the center value is real text. Reduced motion
* renders the arc at its final value with the count complete. One SVG arc via
* stroke-dash + transforms — animates only on value change, zero idle work.
*/
export function Gauge({
value,
max = 100,
variant = "arc",
size = 200,
thickness = 10,
gapAngle = 80,
segmentCount = 24,
stiffness = 80,
damping = 14,
showValue = true,
formatValue,
children,
colors,
thresholds,
ticks = true,
trigger = "in-view",
paused = false,
label = "Gauge",
className,
style,
...props
}: GaugeProps) {
const reducedMotion = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const textRef = React.useRef<HTMLSpanElement>(null);
const trackColor = colors?.track ?? DEFAULT_TRACK;
const baseValueColor = colors?.value ?? DEFAULT_VALUE;
const textColor = colors?.text ?? DEFAULT_TEXT;
const fmt = React.useMemo(() => formatValue ?? ((v: number) => String(Math.round(v))), [formatValue]);
const safeMax = max > 0 ? max : 1;
const clampedValue = clamp(value, 0, safeMax);
// ---- Geometry (all deterministic, viewBox units = px) ----
const isHalf = variant === "half";
const startAngle = isHalf ? 270 : 180 + gapAngle / 2;
const sweep = isHalf ? 180 : clamp(360 - gapAngle, 10, 360);
const cx = size / 2;
const cy = size / 2;
const r = (size - thickness) / 2;
const svgHeight = isHalf ? size / 2 + thickness / 2 : size;
const trackPath = React.useMemo(() => arcPath(cx, cy, r, startAngle, sweep), [cx, cy, r, startAngle, sweep]);
// ---- Sweep spring: value changes re-target it, so every re-sweep starts
// from the current needle position — never from zero. ----
const inViewArmed = useInView(rootRef, { once: true, amount: 0.4 });
const armed = trigger === "mount" || reducedMotion || inViewArmed;
const targetProgress = armed ? clampedValue / safeMax : 0;
const progress = useSpring(0, { stiffness, damping });
React.useEffect(() => {
if (reducedMotion) {
progress.jump(targetProgress);
return;
}
if (paused) {
progress.stop();
return;
}
progress.set(targetProgress);
}, [progress, targetProgress, paused, reducedMotion]);
// Count-up in lockstep with the sweep — written straight to the DOM so the
// component never re-renders per frame. Overshoot reads on the arc; the
// text clamps to [0, max] so it never announces an impossible number.
useMotionValueEvent(progress, "change", (p) => {
if (textRef.current) textRef.current.textContent = fmt(clamp(p, 0, 1) * safeMax);
});
// ---- Threshold bands: hue follows the ANIMATED value, so the shift lands
// as the needle crosses the stop (and eases over ~300ms, never snaps). ----
const sortedStops = React.useMemo(
() => (thresholds ? [...thresholds].sort((a, b) => a.value - b.value) : []),
[thresholds]
);
const colorAt = React.useCallback(
(v: number) => {
let c = baseValueColor;
for (const stop of sortedStops) if (v >= stop.value) c = stop.color;
return c;
},
[baseValueColor, sortedStops]
);
const [activeColor, setActiveColor] = React.useState(baseValueColor);
useMotionValueEvent(progress, "change", (p) => {
setActiveColor(colorAt(clamp(p, 0, 1) * safeMax));
});
React.useEffect(() => {
setActiveColor(colorAt(clamp(progress.get(), 0, 1) * safeMax));
}, [colorAt, safeMax, progress]);
// Hide the round start-cap dot while the arc is empty.
const arcOpacity = useTransform(progress, (p) => (p > 0.003 ? 1 : 0));
// ---- Hairline tick ring (arc/half variants) ----
const tickLines = React.useMemo(() => {
if (!ticks || variant === "segments") return [];
const count = Math.max(2, Math.round(sweep / 7.5));
const outer = r - thickness / 2 - 3;
const lines: { x1: number; y1: number; x2: number; y2: number; major: boolean }[] = [];
for (let i = 0; i <= count; i++) {
const angle = startAngle + (i / count) * sweep;
const major = i % 4 === 0;
const inner = outer - (major ? 5 : 3);
const [x1, y1] = polarPoint(cx, cy, outer, angle);
const [x2, y2] = polarPoint(cx, cy, inner, angle);
lines.push({ x1, y1, x2, y2, major });
}
return lines;
}, [ticks, variant, sweep, r, thickness, startAngle, cx, cy]);
// ---- Segment tick paths ----
const segments = React.useMemo(() => {
if (variant !== "segments") return [];
const count = Math.max(2, Math.floor(segmentCount));
const segAngle = sweep / count;
const pad = Math.min(segAngle * 0.28, 6);
return Array.from({ length: count }, (_, i) => ({
d: arcPath(cx, cy, r, startAngle + i * segAngle + pad / 2, segAngle - pad),
threshold: (i + 0.5) / count,
}));
}, [variant, segmentCount, sweep, cx, cy, r, startAngle]);
const center =
children !== undefined ? (
children
) : showValue ? (
<span
ref={textRef}
className="font-semibold tabular-nums leading-none tracking-tight"
style={{ color: textColor, fontSize: Math.round(size * 0.18) }}
>
{fmt(clamp(progress.get(), 0, 1) * safeMax)}
</span>
) : null;
return (
<div
ref={rootRef}
data-crucible="gauge"
role="meter"
aria-label={label}
aria-valuemin={0}
aria-valuemax={safeMax}
aria-valuenow={clampedValue}
aria-valuetext={fmt(clampedValue)}
className={cn("relative inline-flex", className)}
style={{ width: size, height: svgHeight, ...style }}
{...props}
>
<svg
aria-hidden
width={size}
height={svgHeight}
viewBox={`0 0 ${size} ${svgHeight}`}
className="block overflow-visible"
>
{tickLines.map((t, i) => (
<line
key={i}
x1={t.x1}
y1={t.y1}
x2={t.x2}
y2={t.y2}
stroke={TICK_COLOR}
strokeWidth={1}
opacity={t.major ? 0.55 : 0.3}
/>
))}
{variant === "segments" ? (
segments.map((seg, i) => (
<GaugeSegment
key={i}
d={seg.d}
thickness={thickness}
threshold={seg.threshold}
progress={progress}
litColor={activeColor}
trackColor={trackColor}
reducedMotion={reducedMotion}
/>
))
) : (
<>
<path
d={trackPath}
fill="none"
stroke={trackColor}
strokeWidth={thickness}
strokeLinecap="round"
/>
<motion.path
d={trackPath}
fill="none"
strokeWidth={thickness}
strokeLinecap="round"
style={{ pathLength: progress, opacity: arcOpacity }}
initial={false}
animate={{ stroke: activeColor }}
transition={{ duration: reducedMotion ? 0 : 0.3, ease: "easeOut" }}
/>
</>
)}
</svg>
{center !== null && (
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 flex justify-center",
isHalf ? "items-end" : "items-center"
)}
>
{center}
</div>
)}
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/gaugeManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | Current value. Clamped to [0, max] for the sweep and aria semantics. | |
| max | number | 100 | Value at which the gauge reads full. |
| variant | "arc" | "segments" | "half" | "arc" | Arc style: continuous ring, discrete igniting ticks, or 180° speedometer. |
| size | number | 200 | Outer diameter of the gauge in px (half variant is size wide, half as tall). |
| thickness | number | 10 | Stroke thickness of the arc in px. |
| gapAngle | number | 80 | Opening at the bottom of the ring in degrees (arc/segments variants; half is fixed at 180°). |
| segmentCount | number | 24 | Number of ticks in the segments variant. |
| stiffness | number | 80 | Spring stiffness — higher sweeps faster. |
| damping | number | 14 | Spring damping — lower overshoots further past the mark before settling. |
| showValue | boolean | true | Render the auto count-up value in the center (ignored when children is set). |
| formatValue | (value: number) => string | v => String(Math.round(v)) | Formats the center count-up (and aria-valuetext). |
| children | React.ReactNode | Custom center content — replaces the auto count-up entirely. | |
| colors | GaugeColors | Track / value-arc / center-text colors. | |
| thresholds | GaugeThreshold[] | Ascending color stops; the arc's hue shifts over ~300ms as the sweep crosses each. | |
| ticks | boolean | true | Draw the hairline tick ring inside the arc (arc/half variants). |
| trigger | "in-view" | "mount" | "in-view" | When the first sweep runs: on scroll into view, or immediately on mount. |
| paused | boolean | false | Freezes the needle mid-flight; unpausing resumes toward the target. |
| label | string | "Gauge" | Accessible name for the meter. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.