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
78%Moderate
95%Near Full
108%Overloaded
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.
| Range | Variant | Colour | Meaning |
|---|---|---|---|
| 0–69% | success | #10b981 | Available — capacity to spare |
| 70–89% | warning | #f59e0b | Moderate — approaching full |
| 90–100% | caution | #f97316 | Near Full — limited availability |
| > 100% | danger | #f87171 | Overloaded — 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
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | Current value between 0 and max. |
max | number | 100 | Maximum value. |
size | number | 80 | Diameter in pixels. |
strokeWidth | number | 8 | Track stroke thickness. |
variant | "auto" | "success" | "warning" | "caution" | "danger" | "auto" | Colour override. 'auto' derives from status thresholds (<70% success, <90% warning, <101% caution, >100% danger). |
label | string | — | Short text rendered in the centre of the arc. Defaults to percentage. |
sublabel | string | — | Secondary 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.