Charts

Gauge

A semicircular gauge whose arc retargets smoothly on value changes, with threshold color zones and a springing tabular-nums readout.

Install

npx shadcn@latest add @paragon/gauge

Also installs: number-ticker

gauge.tsx

"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>
  );
}