Gauge

270° arc ring for utilization, capacity, and completion metrics. Auto-colours by threshold — green below 70%, amber below 90%, orange to 100%, red when over. Pairs with the KPI Tile and Team Capacity grid.

Preview

45%Available
Alex C.
78%Moderate
Sam P.
95%Near Full
Dana K.
108%Overloaded
Lee R.

Status thresholds

The autovariant derives colour from utilization percentage. These thresholds match Warren's team-capacity model and are the Sitka convention for all capacity indicators.

RangeVariantColourMeaning
0–69%success#10b981Available — capacity to spare
70–89%warning#f59e0bModerate — approaching full
90–100%caution#f97316Near Full — limited availability
> 100%danger#f87171Overloaded — exceeded capacity

Implementation

Gauge.tsx
tsx
interface GaugeProps {
  value: number;
  max?: number;
  size?: number;
  strokeWidth?: number;
  variant?: "auto" | "success" | "warning" | "caution" | "danger";
  label?: string;
  sublabel?: string;
}

const STATUS_COLORS = {
  success: "rgb(var(--status-success))",
  warning: "rgb(var(--status-warning))",
  caution: "rgb(var(--status-caution))",
  danger:  "rgb(var(--status-danger))",
};

function resolveVariant(pct: number, override: GaugeProps["variant"]) {
  if (override !== "auto") return override!;
  if (pct < 70)  return "success";
  if (pct < 90)  return "warning";
  if (pct < 101) return "caution";
  return "danger";
}

export function Gauge({
  value, max = 100, size = 80, strokeWidth = 8,
  variant = "auto", label, sublabel,
}: GaugeProps) {
  const pct   = Math.min(100, Math.max(0, (value / max) * 100));
  const color = STATUS_COLORS[resolveVariant(pct, variant)];

  // 270° sweep starting at 135° (bottom-left), ending at 45° (bottom-right)
  const SWEEP   = 270;
  const START   = 135;
  const r       = (size - strokeWidth) / 2;
  const cx      = size / 2;
  const cy      = size / 2;

  function polarToXY(deg: number) {
    const rad = (deg * Math.PI) / 180;
    return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
  }

  function arcPath(startDeg: number, sweepDeg: number) {
    const s   = polarToXY(startDeg);
    const e   = polarToXY(startDeg + sweepDeg);
    const large = sweepDeg > 180 ? 1 : 0;
    return `M ${s.x} ${s.y} A ${r} ${r} 0 ${large} 1 ${e.x} ${e.y}`;
  }

  const trackPath = arcPath(START, SWEEP);
  const fillPath  = arcPath(START, (pct / 100) * SWEEP);

  return (
    <div className="flex flex-col items-center gap-1" style={{ width: size }}>
      <div className="relative" style={{ width: size, height: size }}>
        <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
          {/* Track */}
          <path
            d={trackPath}
            fill="none"
            stroke="rgb(var(--progress-track))"
            strokeWidth={strokeWidth}
            strokeLinecap="round"
          />
          {/* Fill */}
          {pct > 0 && (
            <path
              d={fillPath}
              fill="none"
              stroke={color}
              strokeWidth={strokeWidth}
              strokeLinecap="round"
              style={{ transition: "stroke-dasharray 0.4s ease-out" }}
            />
          )}
        </svg>
        {/* Centre label */}
        <div className="absolute inset-0 flex flex-col items-center justify-center">
          <span className="font-bold text-[rgb(var(--text-primary))]" style={{ fontSize: size * 0.17 }}>
            {label ?? `${Math.round(pct)}%`}
          </span>
          {sublabel && (
            <span className="text-[rgb(var(--text-tertiary))]" style={{ fontSize: size * 0.11 }}>
              {sublabel}
            </span>
          )}
        </div>
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
valuenumberCurrent value between 0 and max.
maxnumber100Maximum value.
sizenumber80Diameter in pixels.
strokeWidthnumber8Track stroke thickness.
variant"auto" | "success" | "warning" | "caution" | "danger""auto"Colour override. 'auto' derives from status thresholds (<70% success, <90% warning, <101% caution, >100% danger).
labelstringShort text rendered in the centre of the arc. Defaults to percentage.
sublabelstringSecondary line beneath the label — useful for units (e.g. 'h / 40h').

Accessibility

  • Wrap the SVG in a div with role='img' and aria-label conveying both value and meaning (e.g. 'Sam Park: 78% utilization, Moderate').
  • Colour alone does not convey status — always pair the gauge with a visible text label showing the variant name.
  • The arc is decorative; use aria-hidden='true' on the SVG and expose the accessible value via the wrapper label.
  • At very small sizes (< 48px), the centre percentage may be illegible — use the sublabel to show absolute values (e.g. '34h') instead.