A half-circle gauge with a needle, colored zones and a target mark. The value, the zone it is in and the target are written as text, and the needle sweeps in once and holds still with reduced motion.
72%
91%
34%
"use client"
import { Gauge } from "@/components/ballmac/gauge"
export default function GaugeDemo() {
return (
<div className="grid w-full max-w-3xl gap-8 sm:grid-cols-3">
<Gauge label="Error budget left" value={72} format={(v) => `${v}%`} zones={[{ to: 25, label: "At risk" }, { to: 60, label: "Watch it" }, { to: 100, label: "Healthy" }]} target={50} description="Resets on the 1st" />
<Gauge label="CPU load" value={91} format={(v) => `${v}%`} zones={[{ to: 60, label: "Healthy" }, { to: 85, label: "Watch it" }, { to: 100, label: "At risk" }]} />
<Gauge label="Quota used" value={34} format={(v) => `${v}%`} />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/gaugeAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18n @ballmac/motion-presetsCopy the source into your project.
components/ballmac/gauge.tsx// Ballmac UI: Gauge. https://ui.ballmac.com/components/gauge "use client" import * as React from "react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" import { useReducedMotionSafe } from "@/lib/ballmac/motion" type GaugeZone = { /** Where this zone ends, as a value between min and max. Zones are listed from low to high. */ to: number /** What this zone means, in words, for example "Healthy". */ label: string } type GaugeProps = Omit<React.ComponentProps<"figure">, "children"> & { value: number min?: number max?: number /** Zones from low to high, each with its words. The gauge says which zone the value is in. */ zones?: GaugeZone[] /** A target to mark on the arc. */ target?: number /** What is measured, for example "Error budget left". */ label: string /** Formats the value and the ends. */ format?: (value: number) => string /** A line under the value. */ description?: string } const SHADES = ["var(--destructive)", "var(--chart-3)", "var(--chart-2)"] const R = 80 const CX = 100 const CY = 96 const polar = (t: number, r = R) => { const a = Math.PI * (1 - t) return [CX + r * Math.cos(a), CY - r * Math.sin(a)] as const } const arc = (t0: number, t1: number, r = R) => { const [x0, y0] = polar(t0, r) const [x1, y1] = polar(t1, r) return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r} ${r} 0 0 1 ${x1.toFixed(2)} ${y1.toFixed(2)}` } /** * A half-circle gauge with a needle. The value, the zone it is in and the target are written as text under the arc, so the color and the needle are only a * second way of reading it. The needle sweeps in once and stays still with reduced motion. */ function Gauge({ value, min = 0, max = 100, zones, target, label, format = (v) => String(v), description, className, ...props }: GaugeProps) { const msg = useMessages() const reduced = useReducedMotionSafe() const clamp = (v: number) => Math.min(1, Math.max(0, (v - min) / (max - min || 1))) const t = clamp(value) const [shown, setShown] = React.useState(reduced ? t : 0) React.useEffect(() => { const f = requestAnimationFrame(() => setShown(t)) return () => cancelAnimationFrame(f) }, [t]) const zone = zones?.find((z) => value <= z.to) ?? zones?.[zones.length - 1] const edges = zones ? zones.map((z, i) => [i === 0 ? 0 : clamp(zones[i - 1].to), clamp(z.to)] as const) : null return ( <figure data-slot="gauge" className={cn("grid min-w-0 justify-items-center gap-1 text-center", className)} {...props}> <div role="meter" aria-label={label} aria-valuemin={min} aria-valuemax={max} aria-valuenow={value} aria-valuetext={[format(value), zone?.label].filter(Boolean).join(", ")} className="w-full max-w-64"> <svg aria-hidden="true" viewBox="0 0 200 112" className="w-full overflow-visible"> <path d={arc(0, 1)} fill="none" strokeWidth="14" strokeLinecap="round" className="stroke-muted" /> {edges ? ( edges.map(([a, b], i) => (b > a ? <path key={i} d={arc(a + 0.004, b - 0.004)} fill="none" strokeWidth="14" stroke={SHADES[Math.min(i, SHADES.length - 1)]} opacity={zone === zones?.[i] ? 1 : 0.35} /> : null)) ) : ( <path d={arc(0, 1)} pathLength={1} fill="none" strokeWidth="14" strokeLinecap="round" className="stroke-foreground" strokeDasharray={`${Math.max(0.001, shown)} 1`} style={{ transition: reduced ? undefined : "stroke-dasharray 0.7s ease-out" }} /> )} {target !== undefined && ( <> <line x1={polar(clamp(target), R - 12)[0]} y1={polar(clamp(target), R - 12)[1]} x2={polar(clamp(target), R + 12)[0]} y2={polar(clamp(target), R + 12)[1]} className="stroke-foreground" strokeWidth="2.5" strokeLinecap="round" /> </> )} {edges && ( <g> <line x1={CX} y1={CY} x2={CX - (R - 14)} y2={CY} className="stroke-foreground" strokeWidth="3" strokeLinecap="round" style={{ transformOrigin: `${CX}px ${CY}px`, transform: `rotate(${shown * 180}deg)`, transition: reduced ? undefined : "transform 0.7s cubic-bezier(.2,.8,.2,1)" }} /> <circle cx={CX} cy={CY} r="6" className="fill-foreground" /> <circle cx={CX} cy={CY} r="2.5" className="fill-background" /> </g> )} </svg> </div> <p className="mt-2 text-3xl font-semibold tracking-[-0.03em] tabular-nums">{format(value)}</p> <figcaption className="grid gap-0.5"> <span className="text-sm font-medium">{label}</span> {zone && <span className="text-xs font-medium text-foreground">{zone.label}</span>} {target !== undefined && <span className="text-xs text-muted-foreground">{msg("gauge.target", "Target {value}", { value: format(target) })}</span>} {description && <span className="text-xs text-muted-foreground text-pretty">{description}</span>} </figcaption> <p className="flex w-full max-w-64 justify-between px-[10%] text-[11px] text-muted-foreground tabular-nums" aria-hidden="true"> <span>{format(min)}</span> <span>{format(max)}</span> </p> </figure> ) } export { Gauge, type GaugeProps, type GaugeZone }Update the import paths to match your project setup.
Usage
import { Gauge } from "@/components/ballmac/gauge"The full example is in the Code tab above.
Examples
A 0 to 5 score
4.3
"use client"
import { Gauge } from "@/components/ballmac/gauge"
export default function GaugeSingle() {
return (
<div className="w-full max-w-xs">
<Gauge label="Customer satisfaction" value={4.3} min={0} max={5} format={(v) => v.toFixed(1)} zones={[{ to: 2.5, label: "Poor" }, { to: 4, label: "Good" }, { to: 5, label: "Excellent" }]} target={4.5} />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
value* | number | — |
min | number | 0 |
max | number | 100 |
zonesZones from low to high, each with its words. The gauge says which zone the value is in. | GaugeZone[] | — |
targetA target to mark on the arc. | number | — |
label*What is measured, for example "Error budget left". | string | — |
formatFormats the value and the ends. | (value: number) => string | (v) => String(v) |
descriptionA line under the value. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Meter | role=meter with label, min, max, now and a text value that includes the zone |
| Zone | Written under the value; color only repeats it |
| Motion | The needle sweep is off with reduced motion |
Use with AI
value and label are required; min and max default to 0 and 100. zones is [{ to, label }] from low to high: with zones the arc is colored and the needle is shown, without them the arc fills. target marks a point. format(value) formats the value and ends; description adds a line. It is a role=meter with a spoken value and zone. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Gauge (@ballmac/gauge) to this project with the shadcn MCP, then use it where it fits.
Use it for
- An error budget, quota, load or score at a glance
- A KPI with healthy, watch and at-risk bands
Not for
- Progress toward a finish; use progress or circular-progress
- Trends; use sparkline
Registry JSON: https://ui.ballmac.com/r/gauge.json
Credits
Free to use in personal and commercial projects.
- npm
- None
Pairs well with
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
KPI Row
A compact, semantic row of key metrics that adapts from one to four columns.
Uptime Tracker
A status-page row with one cell per day. It leads with the uptime and the outage count, the cells are one tab stop moved with the arrow keys, and the day you are on is written underneath instead of in a tooltip.
Bullet Chart
Stephen Few's bullet chart: a bar for the value, a mark for the target, three shades behind for poor, fair and good, and a sentence saying whether the goal was met. Many measures in a small space, server-rendered.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.