# Gauge

> Semicircular gauge with animated needle or arc fill, colored threshold zones and meter semantics.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/gauge
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/gauge.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/gauge-demo.tsx"
"use client"

import * as React from "react"

import { Gauge, type GaugeZone } from "@/components/gauge"

const healthZones: GaugeZone[] = [
  { to: 40, tone: "rose", label: "Poor" },
  { to: 70, tone: "amber", label: "Fair" },
  { to: 100, tone: "emerald", label: "Good" },
]

export default function GaugeDemo() {
  const [cpu, setCpu] = React.useState(72)
  const id = React.useId()

  return (
    <div className="grid w-full max-w-2xl min-w-0 grid-cols-[minmax(0,1fr)] gap-6">
      <div className="grid min-w-0 justify-items-center gap-5 rounded-2xl border bg-card p-6 shadow-sm">
        <Gauge value={cpu} label="CPU usage" unit="%" size="lg" />
        <div className="grid w-full max-w-xs gap-2">
          <label htmlFor={id} className="flex justify-between text-sm text-muted-foreground">
            <span>Simulate load</span>
            <span className="tabular-nums">{cpu}%</span>
          </label>
          <input
            id={id}
            type="range"
            min={0}
            max={100}
            value={cpu}
            onChange={(e) => setCpu(Number(e.target.value))}
            className="w-full accent-primary focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40"
          />
        </div>
      </div>
      <div className="grid min-w-0 grid-cols-2 gap-4 rounded-2xl border bg-card p-6 shadow-sm">
        <div className="flex min-w-0 justify-center">
          <Gauge value={84} label="Health score" variant="arc" size="sm" zones={healthZones} />
        </div>
        <div className="flex min-w-0 justify-center">
          <Gauge value={46} min={0} max={120} unit=" ms" label="API latency" variant="arc" size="sm" zones={[{ to: 60, tone: "emerald", label: "Fast" }, { to: 90, tone: "amber", label: "Slow" }, { to: 120, tone: "rose", label: "Degraded" }]} />
        </div>
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/gauge.tsx"
import * as React from "react"

import { cn } from "@/lib/utils"

export type GaugeTone = "emerald" | "amber" | "rose" | "sky" | "violet" | "primary"

export interface GaugeZone {
  /** Upper bound of the zone, in the same units as `value` */
  to: number
  tone: GaugeTone
  /** Short status word, e.g. "Healthy" */
  label?: string
}

export interface GaugeProps extends Omit<React.ComponentProps<"div">, "children"> {
  value: number
  min?: number
  max?: number
  /** Threshold zones from low to high. Defaults to green / amber / red at 60% and 85%. */
  zones?: GaugeZone[]
  /** "needle" shows solid zones with a needle; "arc" fills an arc up to the value */
  variant?: "needle" | "arc"
  size?: "sm" | "md" | "lg"
  /** Accessible name and visible caption */
  label: string
  unit?: string
  format?: (value: number) => string
}

const stroke: Record<GaugeTone, string> = {
  emerald: "stroke-emerald-500 dark:stroke-emerald-400",
  amber: "stroke-amber-500 dark:stroke-amber-400",
  rose: "stroke-rose-500 dark:stroke-rose-400",
  sky: "stroke-sky-500 dark:stroke-sky-400",
  violet: "stroke-violet-500 dark:stroke-violet-400",
  primary: "stroke-primary",
}

const dot: Record<GaugeTone, string> = {
  emerald: "bg-emerald-500 dark:bg-emerald-400",
  amber: "bg-amber-500 dark:bg-amber-400",
  rose: "bg-rose-500 dark:bg-rose-400",
  sky: "bg-sky-500 dark:bg-sky-400",
  violet: "bg-violet-500 dark:bg-violet-400",
  primary: "bg-primary",
}

const sizes = {
  sm: { width: "w-40", value: "text-xl", caption: "text-xs" },
  md: { width: "w-56", value: "text-3xl", caption: "text-sm" },
  lg: { width: "w-72", value: "text-4xl", caption: "text-sm" },
} as const

const CX = 100
const CY = 100
const R = 80
const STROKE = 14
const full = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 })

function pointAt(f: number) {
  const theta = Math.PI * (1 - f)
  return { x: CX + R * Math.cos(theta), y: CY - R * Math.sin(theta) }
}

function arcPath(f0: number, f1: number) {
  const a = pointAt(f0)
  const b = pointAt(f1)
  return `M${a.x.toFixed(2)},${a.y.toFixed(2)} A${R},${R} 0 0 1 ${b.x.toFixed(2)},${b.y.toFixed(2)}`
}

const defaultZones: GaugeZone[] = [
  { to: 60, tone: "emerald", label: "Normal" },
  { to: 85, tone: "amber", label: "Elevated" },
  { to: 100, tone: "rose", label: "Critical" },
]

const keyframes = `@keyframes atelier-gauge-needle{from{transform:rotate(-90deg)}}
@keyframes atelier-gauge-arc{from{stroke-dasharray:0 100}}
@media (prefers-reduced-motion:reduce){.atelier-gauge-anim{animation:none!important;transition:none!important}}`

export function Gauge({
  value,
  min = 0,
  max = 100,
  zones = defaultZones,
  variant = "needle",
  size = "md",
  label,
  unit = "",
  format,
  className,
  ...props
}: GaugeProps) {
  const span = max - min || 1
  const clamp01 = (n: number) => Math.min(Math.max(n, 0), 1)
  const frac = (n: number) => clamp01((n - min) / span)
  const clamped = Math.min(Math.max(value, min), max)
  const f = frac(clamped)
  const fmt = (n: number) => (format ? format(n) : full.format(n))

  const sorted = [...zones].sort((a, b) => a.to - b.to)
  const segments = sorted.map((z, i) => ({
    ...z,
    f0: frac(i === 0 ? min : sorted[i - 1].to),
    f1: frac(z.to),
  }))
  const current = sorted.find((z) => clamped <= z.to) ?? sorted[sorted.length - 1]
  const gap = 0.006

  const s = sizes[size]
  const valueText = `${fmt(clamped)}${unit}${current?.label ? `, ${current.label}` : ""}`
  const needleDeg = -90 + f * 180

  return (
    <div
      role="meter"
      aria-label={label}
      aria-valuemin={min}
      aria-valuemax={max}
      aria-valuenow={clamped}
      aria-valuetext={valueText}
      className={cn("flex max-w-full flex-col items-center", s.width, className)}
      {...props}
    >
      <style>{keyframes}</style>
      <div className="relative w-full">
        <svg viewBox="0 0 200 108" className="block w-full" aria-hidden>
          <path d={arcPath(0, 1)} fill="none" strokeWidth={STROKE} className="stroke-muted" />
          {segments.map((seg, i) => (
            <path
              key={`${seg.to}-${i}`}
              d={arcPath(seg.f0 + (i === 0 ? 0 : gap), seg.f1 - (i === segments.length - 1 ? 0 : gap))}
              fill="none"
              strokeWidth={STROKE}
              className={cn(stroke[seg.tone], variant === "arc" && "opacity-25")}
            />
          ))}
          {variant === "arc" && current && (
            <path
              d={arcPath(0, 1)}
              fill="none"
              strokeWidth={STROKE}
              strokeLinecap="round"
              pathLength={100}
              strokeDasharray={`${f * 100} 100`}
              className={cn("atelier-gauge-anim", stroke[current.tone])}
              style={{
                opacity: f <= 0 ? 0 : 1,
                transition: "stroke-dasharray 800ms cubic-bezier(0.2, 0.8, 0.2, 1), stroke 300ms",
                animation: "atelier-gauge-arc 900ms cubic-bezier(0.2, 0.8, 0.2, 1) both",
              }}
            />
          )}
          {variant === "needle" && (
            <g
              className="atelier-gauge-anim"
              style={{
                transformOrigin: `${CX}px ${CY}px`,
                transform: `rotate(${needleDeg}deg)`,
                transition: "transform 900ms cubic-bezier(0.3, 1.25, 0.5, 1)",
                animation: "atelier-gauge-needle 1100ms cubic-bezier(0.3, 1.25, 0.5, 1) both",
              }}
            >
              <path d={`M${CX - 3.5},${CY} L${CX},${CY - 70} L${CX + 3.5},${CY} Z`} className="fill-foreground" />
            </g>
          )}
          {variant === "needle" && <circle cx={CX} cy={CY} r={8} className="fill-foreground stroke-background" strokeWidth={3} />}
        </svg>
        {variant === "arc" && (
          <div className="absolute inset-x-0 bottom-1 flex flex-col items-center leading-none">
            <span className={cn("font-semibold tracking-tight tabular-nums", s.value)}>
              {fmt(clamped)}
              {unit && <span className="ml-0.5 text-[0.5em] font-medium text-muted-foreground">{unit}</span>}
            </span>
          </div>
        )}
      </div>
      <div className={cn("relative w-full text-center", variant === "arc" ? "h-5" : "pt-2")} aria-hidden>
        <span className="absolute top-0 left-[10%] -translate-x-1/2 text-xs text-muted-foreground tabular-nums">{fmt(min)}</span>
        <span className="absolute top-0 right-[10%] translate-x-1/2 text-xs text-muted-foreground tabular-nums">{fmt(max)}</span>
        {variant === "needle" && (
          <span className={cn("font-semibold tracking-tight tabular-nums", s.value)}>
            {fmt(clamped)}
            {unit && <span className="ml-0.5 text-[0.5em] font-medium text-muted-foreground">{unit}</span>}
          </span>
        )}
      </div>
      <div className={cn("mt-1 flex max-w-full flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-center text-muted-foreground", s.caption)} aria-hidden>
        <span>{label}</span>
        {current?.label && (
          <span className="inline-flex items-center gap-1.5 font-medium text-foreground">
            <span className={cn("size-2 rounded-full", dot[current.tone])} />
            {current.label}
          </span>
        )}
      </div>
    </div>
  )
}
```
