Charts

Gauge

A half-circle dial for one value in a range, with optional judged bands and a target mark.

Built on
role="meter"<svg>pathLength@starting-style
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
2.8 kB
Dependencies
React
Error budget used
Try its options

Loading its options.

Code
import { Gauge } from "@/components/equip/gauge";

export default function Demo() {
  return (
    <Gauge
      label="Error budget used"
      value={62}
      bands={[
        { to: 50, tone: "ok", label: "On track" },
        { to: 80, tone: "warn", label: "Watch" },
        { to: 100, tone: "danger", label: "At risk" },
      ]}
      format={{ style: "unit", unit: "percent" }}
      size={144}
    />
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div role="meter" aria-label="Error budget used" aria-valuenow="62" aria-valuemin="0" aria-valuemax="100" aria-valuetext="62% of 100%, Watch" class="eq-gauge" data-tone="warn" style="--_size:144px;--_t:13px">
  <div class="eq-gauge-dial">
    <svg class="eq-gauge-svg" aria-hidden="true" viewBox="0 0 144 74">
      <path class="eq-gauge-band" data-tone="ok" d="M4 72 A68 68 0 0 1 72 4" />
      <path class="eq-gauge-band" data-tone="warn" d="M72 4 A68 68 0 0 1 127.01 32.03" />
      <path class="eq-gauge-band" data-tone="danger" d="M127.01 32.03 A68 68 0 0 1 140 72" />
      <path class="eq-gauge-track" d="M14.5 72 A57.5 57.5 0 0 1 129.5 72" />
      <path class="eq-gauge-value" d="M14.5 72 A57.5 57.5 0 0 1 129.5 72" pathLength="100" style="--_v:62" />
    </svg>
    <span class="eq-gauge-readout" aria-hidden="true">
      <span class="eq-gauge-number">62%</span>
      <span class="eq-gauge-status">Watch</span>
    </span>
  </div>
  <div class="eq-gauge-range" aria-hidden="true">
    <span>0%</span>
    <span>100%</span>
  </div>
  <span class="eq-gauge-label">Error budget used</span>
</div>

Examples

Score against a target

A target draws a mark across the dial and is named in the text under it and in the value read out.

Net promoter score, October
Code
import { Gauge } from "@/components/equip/gauge";

export default function Example() {
  return (
    <Gauge
      label="Net promoter score, October"
      value={41}
      min={-100}
      max={100}
      target={50}
      bands={[
        { to: 0, tone: "danger", label: "Detractor-led" },
        { to: 30, tone: "warn", label: "Fair" },
        { to: 100, tone: "ok", label: "Good" },
      ]}
      format={{ signDisplay: "exceptZero" }}
      size={200}
    />
  );
}
HTMLworks in any framework, with the part's CSS
<div role="meter" aria-label="Net promoter score, October" aria-valuenow="41" aria-valuemin="-100" aria-valuemax="100" aria-valuetext="+41, range -100 to +100, Good, target +50" class="eq-gauge" data-tone="ok" style="--_size:200px;--_t:18px">
  <div class="eq-gauge-dial">
    <svg class="eq-gauge-svg" aria-hidden="true" viewBox="0 0 200 102">
      <path class="eq-gauge-band" data-tone="danger" d="M4 100 A96 96 0 0 1 100 4" />
      <path class="eq-gauge-band" data-tone="warn" d="M100 4 A96 96 0 0 1 143.58 14.46" />
      <path class="eq-gauge-band" data-tone="ok" d="M143.58 14.46 A96 96 0 0 1 196 100" />
      <path class="eq-gauge-track" d="M17 100 A83 83 0 0 1 183 100" />
      <path class="eq-gauge-value" d="M17 100 A83 83 0 0 1 183 100" pathLength="100" style="--_v:70.5" />
      <path class="eq-gauge-target" d="M150.2 49.8 L170 30" />
    </svg>
    <span class="eq-gauge-readout" aria-hidden="true">
      <span class="eq-gauge-number">+41</span>
      <span class="eq-gauge-status">Good</span>
    </span>
  </div>
  <div class="eq-gauge-range" aria-hidden="true">
    <span>-100</span>
    <span>+100</span>
  </div>
  <span class="eq-gauge-label">Net promoter score, October</span>
  <span class="eq-gauge-goal" aria-hidden="true">Target +50</span>
</div>

A row of service levels

Without bands the arc is plain ink; with them it takes the tone of the band the value is in, and the band's words say why.

CPU, api-gateway
p95 latency, checkout
Replication lag, eu-west-1
Code
import { Gauge } from "@/components/equip/gauge";

const latency = [
  { to: 200, tone: "ok" as const, label: "Fast" },
  { to: 400, tone: "warn" as const, label: "Slow" },
  { to: 600, tone: "danger" as const, label: "Breaching" },
];

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 24, alignItems: "end" }}>
      <Gauge label="CPU, api-gateway" value={38} format={{ style: "unit", unit: "percent" }} size={128} />
      <Gauge label="p95 latency, checkout" value={312} max={600} bands={latency} target={250} format={{ style: "unit", unit: "millisecond", unitDisplay: "narrow" }} size={128} />
      <Gauge label="Replication lag, eu-west-1" value={Number.NaN} max={60} format={{ style: "unit", unit: "second", unitDisplay: "narrow" }} size={128} />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:24px;align-items:end">
  <div role="meter" aria-label="CPU, api-gateway" aria-valuenow="38" aria-valuemin="0" aria-valuemax="100" aria-valuetext="38% of 100%" class="eq-gauge" data-tone="neutral" style="--_size:128px;--_t:12px">
    <div class="eq-gauge-dial">
      <svg class="eq-gauge-svg" aria-hidden="true" viewBox="0 0 128 66">
        <path class="eq-gauge-track" d="M14 64 A50 50 0 0 1 114 64" />
        <path class="eq-gauge-value" d="M14 64 A50 50 0 0 1 114 64" pathLength="100" style="--_v:38" />
      </svg>
      <span class="eq-gauge-readout" aria-hidden="true">
        <span class="eq-gauge-number">38%</span>
      </span>
    </div>
    <div class="eq-gauge-range" aria-hidden="true">
      <span>0%</span>
      <span>100%</span>
    </div>
    <span class="eq-gauge-label">CPU, api-gateway</span>
  </div>
  <div role="meter" aria-label="p95 latency, checkout" aria-valuenow="312" aria-valuemin="0" aria-valuemax="600" aria-valuetext="312ms of 600ms, Slow, target 250ms" class="eq-gauge" data-tone="warn" style="--_size:128px;--_t:12px">
    <div class="eq-gauge-dial">
      <svg class="eq-gauge-svg" aria-hidden="true" viewBox="0 0 128 66">
        <path class="eq-gauge-band" data-tone="ok" d="M4 64 A60 60 0 0 1 34 12.04" />
        <path class="eq-gauge-band" data-tone="warn" d="M34 12.04 A60 60 0 0 1 94 12.04" />
        <path class="eq-gauge-band" data-tone="danger" d="M94 12.04 A60 60 0 0 1 124 64" />
        <path class="eq-gauge-track" d="M14 64 A50 50 0 0 1 114 64" />
        <path class="eq-gauge-value" d="M14 64 A50 50 0 0 1 114 64" pathLength="100" style="--_v:52" />
        <path class="eq-gauge-target" d="M53.39 24.4 L47.69 3.15" />
      </svg>
      <span class="eq-gauge-readout" aria-hidden="true">
        <span class="eq-gauge-number">312ms</span>
        <span class="eq-gauge-status">Slow</span>
      </span>
    </div>
    <div class="eq-gauge-range" aria-hidden="true">
      <span>0ms</span>
      <span>600ms</span>
    </div>
    <span class="eq-gauge-label">p95 latency, checkout</span>
    <span class="eq-gauge-goal" aria-hidden="true">Target 250ms</span>
  </div>
  <div role="meter" aria-label="Replication lag, eu-west-1" aria-valuenow="0" aria-valuemin="0" aria-valuemax="60" aria-valuetext="no data" class="eq-gauge" data-tone="neutral" style="--_size:128px;--_t:12px">
    <div class="eq-gauge-dial">
      <svg class="eq-gauge-svg" aria-hidden="true" viewBox="0 0 128 66">
        <path class="eq-gauge-track" d="M14 64 A50 50 0 0 1 114 64" />
      </svg>
      <span class="eq-gauge-readout" aria-hidden="true">
        <span class="eq-gauge-number" data-empty="true">No data</span>
      </span>
    </div>
    <div class="eq-gauge-range" aria-hidden="true">
      <span>0s</span>
      <span>60s</span>
    </div>
    <span class="eq-gauge-label">Replication lag, eu-west-1</span>
  </div>
</div>

Props

PropTypeDefaultWhat it does
labelstringnoneWhat is measured. Shown under the dial and used as the accessible name.
valuenumbernoneThe measurement. Clamped to min and max; a non-finite value shows no data.
minnumber0Start of the dial.
maxnumber100End of the dial.
bands{ to: number; tone: "ok" | "warn" | "danger"; label: string }[]noneRanges that judge the value, in order, each ending at to. The arc takes the tone of the value's band and its label is shown.
targetnumbernoneDraws a mark across the dial and names it under the dial.
targetLabelstring"Target"Names the target in the text.
childrenReactNodenoneReplaces the formatted value in the middle of the dial.
sizenumber176Width of the dial in pixels. It shrinks to fit a narrower container.
formatIntl.NumberFormatOptionsnoneHow the value, range and target are written.
localestring"en-US"Passed to Intl.NumberFormat.
...restComponentProps<"div">noneEvery native div attribute.

Anatomy

root.eq-gaugerole="meter" with the value, band and target as its value text; data-tone is the band's tone.
dial.eq-gauge-dial
svg.eq-gauge-svg
band.eq-gauge-bandA thin ring outside the track, one arc per band.
track.eq-gauge-track
value.eq-gauge-valueSweeps up from the start on the spring.
target.eq-gauge-target
readout.eq-gauge-readout
number.eq-gauge-numberScales with the dial; data-empty when there is no value.
status.eq-gauge-statusThe band's words with a dot in its tone.
range.eq-gauge-rangeMin and max under the ends of the arc.
label.eq-gauge-label
goal.eq-gauge-goalNames the target under the label.

Notes

  • It is a meter, so assistive tech reads the label, then the value with its band and target as one sentence.
  • Tone always comes with the band's words, so the state never relies on colour alone.
  • Reach for Meter when a value sits in a row or a form; a gauge is for the one number a panel is about.
  • Renders on the server with no client JavaScript.
  • The sweep uses @starting-style; where it is missing the arc is simply there.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/gauge.tsx136 lines
import type { CSSProperties, ComponentProps, ReactNode } from "react";
import "./gauge.css";

export type GaugeBand = {
  /** Where this band ends. Each band starts where the one before it ends, the first at min. */
  to: number;
  tone: "ok" | "warn" | "danger";
  /** Words for the state, such as "Healthy". Shown under the value when it falls in this band. */
  label: string;
};

export type GaugeProps = ComponentProps<"div"> & {
  /** What is measured, such as "Error budget used". Shown under the dial and used as the accessible name. */
  label: string;
  value: number;
  min?: number;
  max?: number;
  /** Ranges that judge the value, in order. The arc takes the tone of the band the value is in. */
  bands?: GaugeBand[];
  /** A mark on the dial for the value you are aiming at. */
  target?: number;
  /** Names the target in the text. */
  targetLabel?: string;
  /** Replaces the formatted value in the middle of the dial. */
  children?: ReactNode;
  /** Width of the dial in pixels; it shrinks to fit a narrower container. */
  size?: number;
  format?: Intl.NumberFormatOptions;
  locale?: string;
};

export function Gauge({
  label,
  value,
  min = 0,
  max = 100,
  bands = [],
  target,
  targetLabel = "Target",
  children,
  size = 176,
  format,
  locale = "en-US",
  className,
  style,
  ...rest
}: GaugeProps) {
  const number = new Intl.NumberFormat(locale, format);
  const top = max > min ? max : min + 1;
  const clamp = (n: number) => Math.min(top, Math.max(min, n));
  const known = Number.isFinite(value);
  const v = clamp(known ? value : min);
  const frac = (n: number) => (clamp(n) - min) / (top - min);

  const t = Math.max(8, Math.round(size / 11));
  const c = size / 2;
  /* Room outside the arc for the band ring and the target mark. */
  const r = c - t / 2 - 8;
  const ring = r + t / 2 + 4;
  const h = c + 2;
  const point = (f: number, radius: number) => {
    const a = Math.PI * (1 - f);
    return `${round(c + radius * Math.cos(a))} ${round(c - radius * Math.sin(a))}`;
  };
  const arc = (f0: number, f1: number, radius: number) => `M${point(f0, radius)} A${radius} ${radius} 0 0 1 ${point(f1, radius)}`;

  const spans = bands
    .filter((b) => Number.isFinite(b.to))
    .map((b, i, all) => ({ ...b, from: i === 0 ? min : (all[i - 1]?.to ?? min) }))
    .filter((b) => b.to > b.from);
  const band = known ? spans.find((b, i) => v <= b.to || i === spans.length - 1) : undefined;
  const hasTarget = typeof target === "number" && Number.isFinite(target);

  const text = !known
    ? "no data"
    : [
        min === 0 ? `${number.format(v)} of ${number.format(top)}` : `${number.format(v)}, range ${number.format(min)} to ${number.format(top)}`,
        band?.label,
        hasTarget ? `${targetLabel.toLowerCase()} ${number.format(target)}` : undefined,
      ]
        .filter(Boolean)
        .join(", ");

  return (
    <div
      role="meter"
      aria-label={label}
      aria-valuenow={v}
      aria-valuemin={min}
      aria-valuemax={top}
      aria-valuetext={text}
      {...rest}
      className={["eq-gauge", className].filter(Boolean).join(" ")}
      data-tone={band?.tone ?? "neutral"}
      style={{ ...style, "--_size": `${size}px`, "--_t": `${t}px` } as CSSProperties}
    >
      <div className="eq-gauge-dial">
        <svg className="eq-gauge-svg" aria-hidden="true" viewBox={`0 0 ${size} ${h}`}>
          {spans.map((b, i) => (
            <path key={i} className="eq-gauge-band" data-tone={b.tone} d={arc(frac(b.from), frac(b.to), ring)} />
          ))}
          <path className="eq-gauge-track" d={arc(0, 1, r)} />
          {known && (
            <path className="eq-gauge-value" d={arc(0, 1, r)} pathLength={100} style={{ "--_v": round(frac(v) * 100) } as CSSProperties} />
          )}
          {hasTarget && (
            <path
              className="eq-gauge-target"
              d={`M${point(frac(target), r - t / 2 - 3)} L${point(frac(target), ring + 3)}`}
            />
          )}
        </svg>
        <span className="eq-gauge-readout" aria-hidden="true">
          <span className="eq-gauge-number" data-empty={!known || undefined}>
            {children ?? (known ? number.format(v) : "No data")}
          </span>
          {band && <span className="eq-gauge-status">{band.label}</span>}
        </span>
      </div>
      <div className="eq-gauge-range" aria-hidden="true">
        <span>{number.format(min)}</span>
        <span>{number.format(top)}</span>
      </div>
      <span className="eq-gauge-label">{label}</span>
      {hasTarget && (
        <span className="eq-gauge-goal" aria-hidden="true">
          {`${targetLabel} ${number.format(target)}`}
        </span>
      )}
    </div>
  );
}

function round(n: number) {
  return Math.round(n * 100) / 100;
}
components/equip/gauge.css152 lines
@layer equip {
  .eq-gauge {
    --_fill: var(--eq-ink);

    display: inline-grid;
    justify-items: center;
    gap: var(--eq-space-1);
    width: min(100%, var(--_size));
    font: var(--eq-weight) var(--eq-text-xs) / 1.3 var(--eq-font);
    color: var(--eq-ink-2);
    vertical-align: middle;
  }

  .eq-gauge[data-tone="ok"] {
    --_fill: var(--eq-ok);
  }

  .eq-gauge[data-tone="warn"] {
    --_fill: var(--eq-warn);
  }

  .eq-gauge[data-tone="danger"] {
    --_fill: var(--eq-danger);
  }

  .eq-gauge-dial {
    position: relative;
    width: 100%;
  }

  .eq-gauge-svg {
    display: block;
    width: 100%;
    height: auto;
    overflow: visible;
  }

  .eq-gauge-track,
  .eq-gauge-value {
    fill: none;
    stroke-width: var(--_t);
  }

  .eq-gauge-track {
    stroke: var(--eq-tint-strong);
  }

  /* A measurement on a dial: it sweeps up from the start on the spring, like a needle settling. */
  .eq-gauge-value {
    stroke: var(--_fill);
    stroke-dasharray: var(--_v) 100;
    transition: stroke-dasharray var(--eq-dur-move) var(--eq-spring);
  }

  @starting-style {
    .eq-gauge-value {
      stroke-dasharray: 0 100;
    }
  }

  .eq-gauge-band {
    fill: none;
    stroke-width: 3;
  }

  .eq-gauge-band[data-tone="ok"] {
    stroke: color-mix(in srgb, var(--eq-ok) 70%, transparent);
  }

  .eq-gauge-band[data-tone="warn"] {
    stroke: color-mix(in srgb, var(--eq-warn) 70%, transparent);
  }

  .eq-gauge-band[data-tone="danger"] {
    stroke: color-mix(in srgb, var(--eq-danger) 70%, transparent);
  }

  .eq-gauge-target {
    fill: none;
    stroke: var(--eq-ink);
    stroke-width: 2;
    stroke-linecap: round;
  }

  .eq-gauge-readout {
    position: absolute;
    inset-inline: 0;
    bottom: 0;
    display: grid;
    justify-items: center;
    padding-inline: calc(var(--_t) + var(--eq-space-2));
    text-align: center;
  }

  .eq-gauge-number {
    max-width: 100%;
    overflow: hidden;
    color: var(--eq-ink);
    /* Scales with the dial, between the body size and one and a half times the large size. */
    font-size: clamp(var(--eq-text-m), calc(var(--_size) * 0.13), calc(var(--eq-text-l) * 1.5));
    font-weight: var(--eq-weight-strong);
    font-variant-numeric: tabular-nums;
    line-height: 1.1;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .eq-gauge-number[data-empty] {
    color: var(--eq-ink-3);
    font-size: var(--eq-text-s);
    font-weight: var(--eq-weight);
  }

  /* The state in words, with a dot in its tone, so the colour is never the only signal. */
  .eq-gauge-status {
    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-1);
    color: var(--eq-ink);
    font-weight: var(--eq-weight-strong);
  }

  .eq-gauge-status::before {
    content: "";
    width: 6px;
    height: 6px;
    border-radius: var(--eq-radius-full);
    background: var(--_fill);
  }

  .eq-gauge-range {
    display: flex;
    justify-content: space-between;
    gap: var(--eq-space-2);
    width: 100%;
    color: var(--eq-ink-3);
    font-variant-numeric: tabular-nums;
  }

  .eq-gauge-goal {
    margin-block-start: calc(var(--eq-space-1) * -1);
    color: var(--eq-ink-2);
    font-variant-numeric: tabular-nums;
  }

  .eq-gauge-label {
    color: var(--eq-ink);
    font-size: var(--eq-text-s);
    font-weight: var(--eq-weight-strong);
    text-align: center;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index