Gauge
A dial reading a value against its scale, with thresholds that turn the arc from success to danger.
Server-compatible inline SVG: no hooks, no charting library. describeArc(cx, cy, r, startAngle, endAngle) and gaugeTone(value, thresholds, direction) are exported and tested; angles are degrees with 0 at twelve o'clock, running clockwise. The root is the meter and the drawing is hidden from screen readers; a label that is not plain text cannot name it, so the scale stands in as the name until you pass aria-label. The value sits in the middle as text, so the tone never carries the reading alone.
Install
npx shadcn@latest add @vibra/gaugeNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Gauge } from "@/components/ui/gauge"
export default function GaugeDemo() {
return (
<div className="flex w-full max-w-sm items-end justify-center gap-6 panel p-6">
<Gauge
value={99.4}
label="Uptime"
size={132}
format={(v) => `${v.toFixed(1)}%`}
/>
<Gauge value={68} arc={270} label="Error budget" size={132} />
</div>
)
}Threshold tones
One set of thresholds across three readings, stepping from success to warning to danger.
import { Gauge } from "@/components/ui/gauge"
// One set of thresholds, three readings — the tone steps from success to
// warning to danger without any of the gauges naming a colour.
const THRESHOLDS = { warning: 70, danger: 90 }
const HOSTS = [
{ label: "CPU", value: 42 },
{ label: "Memory", value: 78 },
{ label: "Disk", value: 94 },
]
export default function GaugeThresholds() {
return (
<div className="flex w-full max-w-md flex-wrap items-end justify-center gap-4 panel p-6">
{HOSTS.map((host) => (
<Gauge
key={host.label}
value={host.value}
label={host.label}
thresholds={THRESHOLDS}
size={108}
thickness={8}
format={(v) => `${v}%`}
/>
))}
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | The reading, in the units of the scale. |
| min | number | 0 | Bottom of the scale, where the arc starts. |
| max | number | 100 | Top of the scale, where the arc ends. |
| label | React.ReactNode | — | Sits under the dial, and names the meter when it is plain text. |
| format | (v: number) => string | whole units with separators | Formats the number in the middle of the dial. |
| size | number | 160 | Width of the dial in pixels; the stroke, type, and box scale from it. |
| thickness | number | 10 | Stroke width of the track and the value arc. |
| tone | "default" | "success" | "warning" | "danger" | "info" | "auto" | "auto" | auto reads the tone off the thresholds; anything else paints the arc outright. |
| thresholds | { warning?: number; danger?: number } | — | Where the auto tone turns; with neither set, auto stays success. |
| direction | "higher-is-worse" | "higher-is-better" | "higher-is-worse" | Which end of the scale is the good one: thresholds are ceilings on a load meter, and the same numbers read as floors on an attainment dial. |
| arc | 180 | 270 | 180 | Degrees the dial sweeps. |
Dependencies
Registry
Source
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { clamp, formatNumber, percentOf } from "@/lib/format"
type Tone = "default" | "success" | "warning" | "danger" | "info"
/** Where each sweep starts and ends, in the degrees describeArc speaks: 0 at the top, running clockwise. */
const ARC_ANGLES = {
180: { start: -90, end: 90 },
270: { start: -135, end: 135 },
} as const
/** Trims float noise so paths stay short and identical between server and client renders. */
function round(n: number) {
return Number(n.toFixed(3))
}
function polarToCartesian(cx: number, cy: number, r: number, angle: number) {
const radians = ((angle - 90) * Math.PI) / 180
return { x: round(cx + r * Math.cos(radians)), y: round(cy + r * Math.sin(radians)) }
}
/**
* The SVG path for the arc of radius `r` around (`cx`, `cy`) that runs from
* `startAngle` to `endAngle`, in degrees with 0 at twelve o'clock. An empty
* range returns the bare moveto, which paints nothing — that is what keeps a
* gauge at the bottom of its scale from showing a stub of colour.
*/
export function describeArc(
cx: number,
cy: number,
r: number,
startAngle: number,
endAngle: number
): string {
const start = polarToCartesian(cx, cy, r, startAngle)
if (startAngle === endAngle) return `M ${start.x} ${start.y}`
const end = polarToCartesian(cx, cy, r, endAngle)
const largeArc = Math.abs(endAngle - startAngle) > 180 ? 1 : 0
const sweep = endAngle > startAngle ? 1 : 0
return `M ${start.x} ${start.y} A ${round(r)} ${round(r)} 0 ${largeArc} ${sweep} ${end.x} ${end.y}`
}
/** Which end of the scale is the good one: a load meter reads high as bad, an attainment dial reads it as good. */
export type GaugeDirection = "higher-is-worse" | "higher-is-better"
/**
* The tone an auto gauge takes. On a "higher-is-worse" dial the thresholds are
* ceilings — danger from the danger one up, warning from the warning one — and
* on a "higher-is-better" dial they are the same two numbers read as floors,
* danger below the danger one and warning below the warning one. Either way a
* threshold that is not set cannot make the reading bad.
*/
export function gaugeTone(
value: number,
thresholds: { warning?: number; danger?: number } = {},
direction: GaugeDirection = "higher-is-worse"
): Tone {
const past = (limit: number) => (direction === "higher-is-better" ? value < limit : value >= limit)
if (thresholds.danger !== undefined && past(thresholds.danger)) return "danger"
if (thresholds.warning !== undefined && past(thresholds.warning)) return "warning"
return "success"
}
// The arc paints with `stroke="currentColor"`, so the tone only ever sets a
// text colour and never a hard-coded stroke.
const gaugeArcVariants = cva("", {
variants: {
tone: {
default: "text-primary",
success: "text-success",
warning: "text-warning",
danger: "text-danger",
info: "text-info",
},
},
defaultVariants: { tone: "default" },
})
const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
export type GaugeProps = React.ComponentProps<"div"> & {
value: number
min?: number
max?: number
/** Sits under the dial, and names the meter when it is plain text. */
label?: React.ReactNode
format?: (v: number) => string
/** Width of the dial in pixels; the stroke, type, and box all scale from it. */
size?: number
/** Stroke width of the track and the value arc. */
thickness?: number
/** "auto" reads the tone off the thresholds. */
tone?: Tone | "auto"
/** Where the auto tone turns. With neither threshold set, auto stays success. */
thresholds?: { warning?: number; danger?: number }
/** Which end of the scale is the good one, which is what the thresholds are read against. */
direction?: GaugeDirection
/** Degrees the dial sweeps. */
arc?: 180 | 270
}
function Gauge({
className,
value,
min = 0,
max = 100,
label,
format = DEFAULT_FORMAT,
size = 160,
thickness = 10,
tone = "auto",
thresholds,
direction = "higher-is-worse",
arc = 180,
"aria-label": ariaLabel,
...props
}: GaugeProps) {
const resolvedTone = tone === "auto" ? gaugeTone(value, thresholds, direction) : tone
const { start, end } = ARC_ANGLES[arc]
const center = size / 2
const radius = (size - thickness) / 2
// The box stops at the lowest point the sweep reaches, so a half dial leaves
// no dead space under itself and a three-quarter one still fits its ends.
const height = round(center - radius * Math.cos((end * Math.PI) / 180) + thickness / 2)
const fraction = clamp(percentOf(value - min, max - min) / 100, 0, 1)
const reading = format(value)
return (
<div
data-slot="gauge"
data-tone={resolvedTone}
data-arc={arc}
data-direction={direction}
role="meter"
// The dial is the meter itself, so its own name and value carry the
// reading. A ReactNode label cannot become a name, so the scale stands in
// rather than leaving the meter unnamed — pass `aria-label` to say what
// is being measured.
aria-label={
ariaLabel ??
(typeof label === "string" ? label : `Gauge from ${format(min)} to ${format(max)}`)
}
aria-valuenow={clamp(value, min, max)}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={reading}
className={cn("inline-flex flex-col items-center gap-1", className)}
{...props}
>
<svg
width={size}
height={height}
viewBox={`0 0 ${size} ${height}`}
aria-hidden="true"
className="overflow-visible"
>
<path
data-slot="gauge-track"
d={describeArc(center, center, radius, start, end)}
className="stroke-muted"
fill="none"
strokeWidth={thickness}
strokeLinecap="round"
/>
<path
data-slot="gauge-arc"
d={describeArc(center, center, radius, start, start + (end - start) * fraction)}
className={gaugeArcVariants({ tone: resolvedTone })}
stroke="currentColor"
fill="none"
strokeWidth={thickness}
strokeLinecap="round"
/>
<text
data-slot="gauge-value"
x={center}
y={center}
textAnchor="middle"
// A half dial reads best sitting on its diameter; a deeper sweep
// wraps the number, so that one centres.
dominantBaseline={arc === 180 ? "auto" : "central"}
fontSize={round(size * 0.2)}
className="fill-foreground font-semibold tabular-nums"
>
{reading}
</text>
</svg>
{label ? (
<div data-slot="gauge-label" className="text-sm text-muted-foreground">
{label}
</div>
) : null}
</div>
)
}
export { Gauge, gaugeArcVariants }