Skip to contentVibraUI
Metrics

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/gauge

Needs the @vibra registry in your components.json — set it up once.

Examples

Threshold tones

One set of thresholds across three readings, stepping from success to warning to danger.

Props

PropTypeDefaultDescription
valuenumber—The reading, in the units of the scale.
minnumber0Bottom of the scale, where the arc starts.
maxnumber100Top of the scale, where the arc ends.
labelReact.ReactNode—Sits under the dial, and names the meter when it is plain text.
format(v: number) => stringwhole units with separatorsFormats the number in the middle of the dial.
sizenumber160Width of the dial in pixels; the stroke, type, and box scale from it.
thicknessnumber10Stroke 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.
arc180 | 270180Degrees the dial sweeps.

Dependencies

Source

components/ui/gauge.tsx
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 }