Gauge · MeterBar

Single-value progress: an open ring whose arc and centre count-up share one reveal clock, and a tone-coloured semantic meter bar that holds a completed still frame when motion is off.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/gauge.json
Chart · canvas + DOMcanvas
Uptime82
Disk64
Error budget93
single-value · semantic

Gauge · MeterBaresc or double-click to exit full screendouble-tap to exit

data/gauge.tsx
"use client";

import * as React from "react";
import {
  cx,
  DEFAULT_LANTERN,
  DEFAULT_WISP,
  hsl,
  mixHsl,
  useCanvasScene,
  useReducedMotion,
  useRevealClock,
  useTokenColors,
  useWispGain,
  type Hsl,
} from "@pouriahlabs/wisp-ui";

/**
 * Gauge — a single value, drawn as an open ring that sweeps in while the centre
 * number counts up on the same clock.
 *
 * This is ArcReveal's single-value sibling. ArcReveal answers "what is this made
 * of"; a gauge answers "how full is it" — one figure against a ceiling, the
 * shape a storage quota, a rate limit or a completion percentage actually wants.
 * The ring is open (270° by default) because a value against a maximum has a
 * floor and a ceiling, and a closed donut hides where those are: the two ends of
 * the arc *are* empty and full, and the gap at the bottom is what makes that
 * legible at a glance.
 *
 * There is one clock. The arc and the centre number share the reveal exactly as
 * ArcReveal's ring and total do — the number is written straight to the DOM from
 * inside the draw loop, not held in React state, so the last degree of sweep and
 * the last digit land on the same frame instead of a re-render apart. A count-up
 * that finishes a frame after the arc it describes is the tell of two timers; here
 * there is only one, so there is nothing to desync.
 *
 * The fill is the seam. Rather than one flat accent it is painted across the arc
 * from `--wisp` to `--lantern`, so the value carries a direction — emptier reads
 * cooler, fuller reads warmer — without introducing a colour the palette doesn't
 * already own. Name semantic tokens on `fromToken`/`toToken` when a gauge is a
 * health bar rather than a neutral quota.
 *
 * The canvas is decoration. The ring wrapper is `role="img"` with an
 * `aria-label` carrying the settled value and maximum, so the gauge survives a
 * screen reader, a text-only render and a failed paint — a bare canvas is not a
 * gauge, it is a decoration that used to be one.
 */

export interface GaugeProps extends React.HTMLAttributes<HTMLDivElement> {
  /** The value to display. */
  value: number;
  /** The full-scale ceiling `value` is measured against. */
  max?: number;
  /** Ring diameter, CSS pixels. */
  size?: number;
  /** Ring thickness, CSS pixels. */
  thickness?: number;
  /** Sweep duration, ms. Ignored when `progress` is supplied. */
  duration?: number;
  /** Externally driven reveal, `0`–`1`. Omit to self-animate. */
  progress?: number;
  /**
   * Pause before replaying, ms. `0` (the default) sweeps once and holds — a
   * dashboard gauge shouldn't reset every few seconds. Set it for a demo.
   */
  loop?: number;
  /** Where the arc starts, degrees clockwise from twelve o'clock. Defaults to
   *  the bottom-left, so a 270° sweep leaves the gap centred at six o'clock. */
  startAngle?: number;
  /** How far the full track spans, degrees. 270 is the open-ring default. */
  sweepAngle?: number;
  /** Token for the empty end of the fill. */
  fromToken?: string;
  /** Token for the full end of the fill — the seam runs `fromToken`→`toToken`. */
  toToken?: string;
  /** Format the centre number. Overrides `locale`. */
  format?: (value: number) => string;
  /** Locale for the default number's grouping separators. Pass `false` for none. */
  locale?: string | false;
  /** Small line under the centre number. */
  caption?: string;
}

const SEAM: [Hsl, Hsl] = [DEFAULT_WISP, DEFAULT_LANTERN];

const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n);

export function Gauge({
  value,
  max = 100,
  size = 132,
  thickness = 12,
  duration = 1300,
  progress,
  loop = 0,
  startAngle = 135,
  sweepAngle = 270,
  fromToken = "--wisp",
  toToken = "--lantern",
  locale = "en-US",
  format = (n) =>
    locale === false ? String(Math.round(n)) : Math.round(n).toLocaleString(locale),
  caption,
  className,
  style,
  ...props
}: GaugeProps) {
  const ref = React.useRef<HTMLCanvasElement>(null);
  const numRef = React.useRef<HTMLSpanElement>(null);

  const [from, to] = useTokenColors([fromToken, toToken], SEAM);
  const gain = useWispGain();
  // The public props are ms; the canvas ticks the reveal clock in seconds.
  const { tick, reset } = useRevealClock({ duration: duration / 1000, loop: loop / 1000, progress });

  // The share of the track the value fills. Kept out of the draw loop's hot
  // path — it changes with props, not with the frame.
  const frac = max > 0 ? clamp01(value / max) : 0;

  useCanvasScene(ref, {
    setup: () => reset(),

    draw: ({ ctx, width, height, dt, still }) => {
      ctx.clearRect(0, 0, width, height);
      if (width < 8 || height < 8) return;

      const t = tick(dt, still);
      // Driven reveals repaint on the parent's render; a self-animating one
      // holds once finished and not looping — either way the loop can stop.
      const done = progress !== undefined || (loop === 0 && t >= 1);

      // The one clock, written straight to the DOM rather than through state: a
      // re-render per frame to move one text node is the wrong trade, and going
      // through React is also how the number ends up a frame behind the arc it
      // is describing. The value counts, not the fraction — a quota reads "72",
      // not "0.72".
      if (numRef.current) numRef.current.textContent = format(value * t);

      const cx0 = width / 2;
      const cy0 = height / 2;
      const radius = Math.min(width, height) / 2 - thickness / 2 - 1;
      if (radius <= 0) return done;

      const start = (startAngle * Math.PI) / 180;
      const sweep = (sweepAngle * Math.PI) / 180;

      // The track: without it a part-filled gauge reads as a short arc floating
      // in space rather than a value sitting low against its ceiling.
      const base = from ?? SEAM[0];
      ctx.strokeStyle = hsl(base, base[2], 0.12 * gain);
      ctx.lineWidth = thickness;
      ctx.lineCap = "round";
      ctx.beginPath();
      ctx.arc(cx0, cy0, radius, start, start + sweep);
      ctx.stroke();

      // The fill, painted across the arc as the seam. Subdividing lets the
      // gradient run `fromToken`→`toToken` along the *whole* sweep, so a
      // segment's colour depends on where it sits on the track, not on how far
      // the reveal has come — the hue at a given angle is stable as it fills in.
      const filled = sweep * frac * t;
      if (filled > 1e-4) {
        const steps = Math.max(2, Math.ceil(filled / (Math.PI / 90)));
        ctx.lineCap = "round";
        for (let i = 0; i < steps; i++) {
          const a0 = start + (filled * i) / steps;
          // A hair of overlap so butt-joined segments leave no gap on the seam.
          const a1 = start + (filled * (i + 1)) / steps + 1e-3;
          const pos = (a0 - start + a1 - start) / 2 / sweep;
          const color = mixHsl(from ?? SEAM[0], to ?? SEAM[1], clamp01(pos));
          ctx.strokeStyle = hsl(color, color[2], 0.95);
          ctx.beginPath();
          ctx.arc(cx0, cy0, radius, a0, a1);
          ctx.stroke();
        }
      }

      return done;
    },
  });

  return (
    <div className={cx("wisp-gauge", className)} style={style} {...props}>
      <div
        className="wisp-gauge-ring"
        style={{ position: "relative", flex: "none", width: size, height: size }}
        // The canvas is aria-hidden; this label is the gauge as far as a screen
        // reader is concerned. It carries the settled value, not the animating
        // one — a count-up announced digit by digit is noise, not information.
        role="img"
        aria-label={`${format(value)} of ${format(max)}`}
      >
        <canvas ref={ref} aria-hidden className="block h-full w-full" />
        <span
          className="wisp-gauge-center"
          aria-hidden
          style={{
            position: "absolute",
            inset: 0,
            display: "flex",
            flexDirection: "column",
            alignItems: "center",
            justifyContent: "center",
            gap: 1,
            pointerEvents: "none",
          }}
        >
          {/* Rendered settled, then overwritten every frame. Without JavaScript,
              or before the first frame, this is already the true value. */}
          <span
            ref={numRef}
            className="wisp-gauge-value tabular-nums"
            style={{ fontSize: "1.25rem", lineHeight: 1.05, letterSpacing: "-0.025em" }}
          >
            {format(value)}
          </span>
          {caption ? (
            <span
              className="wisp-gauge-caption"
              style={{ fontSize: "0.6875rem", letterSpacing: "0.14em", textTransform: "uppercase", opacity: 0.7 }}
            >
              {caption}
            </span>
          ) : null}
        </span>
      </div>
    </div>
  );
}

/**
 * MeterBar — a linear meter whose fill is coloured by sentiment.
 *
 * The horizontal counterpart to Gauge, and the one to reach for when the number
 * is answering "is this healthy" rather than "how full is it". Where Gauge is a
 * neutral quota painted along the seam, a MeterBar's colour is semantic: `tone`
 * maps to `--wisp-up` (a good level), `--wisp-down` (a critical one) or a warning
 * token, kept deliberately separate from the brand accent for the same reason
 * DeltaChip is — a bar that turns the accent colour when a value goes critical
 * has told the reader nothing, because the accent is also just "on brand".
 *
 * No canvas. It is a track and a fill div, so it renders correctly on the server
 * and needs no measurement — the value is known, not observed. The fill
 * transitions its width, and that transition is switched off under reduced
 * motion via `useReducedMotion` rather than left to a media query, so the
 * still-frame contract is met in JavaScript: a reader who asked to hold still
 * sees the completed bar at the correct width immediately, never a widening one.
 *
 * The bar is `role="progressbar"` with the real `aria-valuenow`/`min`/`max`, so
 * the level is announced as a value, not inferred from a coloured rectangle.
 */

export type MeterTone = "brand" | "success" | "critical" | "warning";

export interface MeterBarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** The value to display. */
  value: number;
  /** The full-scale ceiling `value` is measured against. */
  max?: number;
  /** Which semantic token colours the fill. `brand` is the neutral default. */
  tone?: MeterTone;
  /** Label shown above the bar, and the bar's accessible name. */
  label?: string;
  /** Show the numeric value beside the label. */
  showValue?: boolean;
}

// Semantic tokens, each with the channel fallback used before CSS resolves — a
// var() reference so the fill re-colours on a theme swap for free, without the
// component re-reading anything.
const TONE_TOKEN: Record<MeterTone, { token: string; fallback: string }> = {
  brand: { token: "--wisp", fallback: "162 72% 52%" },
  success: { token: "--wisp-up", fallback: "162 72% 52%" },
  critical: { token: "--wisp-down", fallback: "356 64% 60%" },
  warning: { token: "--wisp-warn", fallback: "42 88% 62%" },
};

export function MeterBar({
  value,
  max = 100,
  tone = "brand",
  label,
  showValue = false,
  className,
  style,
  ...props
}: MeterBarProps) {
  const still = useReducedMotion();
  const frac = max > 0 ? clamp01(value / max) : 0;

  // Start empty and fill in an effect so the width transition actually plays on
  // mount; a bar rendered at its final width has nothing to animate from.
  const [shown, setShown] = React.useState(0);
  React.useEffect(() => setShown(frac), [frac]);

  // The still frame is the completed bar. Reading `frac` directly under reduced
  // motion — rather than the animated `shown` — means it is correct on the first
  // painted frame, independent of when the fill effect runs.
  const width = `${(still ? frac : shown) * 100}%`;
  const { token, fallback } = TONE_TOKEN[tone];
  const color = `hsl(var(${token}, ${fallback}))`;

  return (
    <div className={cx("wisp-meter", className)} style={style} {...props}>
      {label || showValue ? (
        <div
          className="wisp-meter-head"
          style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 8, marginBottom: 6 }}
        >
          {label ? <span className="wisp-meter-label">{label}</span> : <span />}
          {showValue ? (
            <span className="wisp-meter-num tabular-nums" style={{ fontVariantNumeric: "tabular-nums", opacity: 0.75 }}>
              {value}
            </span>
          ) : null}
        </div>
      ) : null}

      <div
        className="wisp-meter-track"
        role="progressbar"
        aria-valuenow={Math.round(value)}
        aria-valuemin={0}
        aria-valuemax={Math.round(max)}
        aria-label={label}
        style={{
          position: "relative",
          height: 8,
          borderRadius: 999,
          overflow: "hidden",
          background: `hsl(var(${token}, ${fallback}) / 0.14)`,
        }}
      >
        <div
          className="wisp-meter-fill"
          style={{
            height: "100%",
            width,
            borderRadius: 999,
            background: color,
            // Switched off, not just shortened, under reduced motion — the still
            // frame is a completed bar, not a fast one.
            transition: still ? "none" : "width 700ms cubic-bezier(0.22, 1, 0.36, 1)",
          }}
        />
      </div>
    </div>
  );
}

Certified against the contract