# Gauge — Animated Data Visualization for React

> 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.

- Page: https://crucibleui.com/components/data/gauge
- Category: [data](https://crucibleui.com/components/data)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/gauge
```

Registry JSON: https://crucibleui.com/r/gauge.json

Import from `@/components/crucible/gauge`.

## Props (`GaugeProps`)

| 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: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Gauge } from "@/components/crucible/gauge";

const VALUES = [64, 27, 91];

export default function GaugeDemo() {
  const [step, setStep] = React.useState(0);
  const value = VALUES[step % VALUES.length];

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div className="relative z-10 flex flex-col items-center gap-8 px-6">
        <div className="flex flex-wrap items-end justify-center gap-10">
          <div className="flex flex-col items-center gap-3">
            <Gauge value={value} size={150} thickness={9} label="Arc gauge" />
            <p className="text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-500">Arc</p>
          </div>
          <div className="flex flex-col items-center gap-3">
            <Gauge value={value} variant="segments" size={150} thickness={9} label="Segmented gauge" />
            <p className="text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-500">Segments</p>
          </div>
          <div className="flex flex-col items-center gap-3">
            <Gauge
              value={value}
              variant="half"
              size={168}
              thickness={9}
              thresholds={[
                { value: 60, color: "#e0b25c" },
                { value: 85, color: "#e2654f" },
              ]}
              label="Half gauge with thresholds"
            />
            <p className="text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-500">
              Half + thresholds
            </p>
          </div>
        </div>
        <button
          type="button"
          onClick={() => setStep((s) => s + 1)}
          className="rounded-full border border-neutral-800 bg-neutral-900 px-4 py-1.5 text-xs font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
        >
          Change the value — each sweep starts from where the needle rests
        </button>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`gauge.tsx`)

```tsx
"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 "@/hooks/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>
  );
}
```
