Chart Gauge
Original · freesemicircle gauge chart
byWeb InnoventixReact + Tailwind
chartgaugecharts
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/chart-gauge.jsonchart-gauge.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { ChangeEvent } from "react";
import { animate, useReducedMotion } from "motion/react";
type ZoneKey = "critical" | "degraded" | "healthy";
interface Zone {
key: ZoneKey;
label: string;
range: string;
min: number;
max: number;
arcClass: string;
dotClass: string;
textClass: string;
advice: string;
}
interface Preset {
label: string;
hint: string;
value: number;
}
const CX = 200;
const CY = 200;
const R = 150;
const SW = 24;
const TICKS = [0, 25, 50, 75, 100];
const ZONES: Zone[] = [
{
key: "critical",
label: "Critical",
range: "0–39",
min: 0,
max: 40,
arcClass: "stroke-rose-500",
dotClass: "bg-rose-500",
textClass: "text-rose-600 dark:text-rose-400",
advice:
"The SLA is actively breaching. Fail traffic over to the secondary regions and page the on-call engineer now.",
},
{
key: "degraded",
label: "Degraded",
range: "40–69",
min: 40,
max: 70,
arcClass: "stroke-amber-500",
dotClass: "bg-amber-500",
textClass: "text-amber-600 dark:text-amber-400",
advice:
"Latency is drifting toward the SLA line. Warm extra workers and pre-scale the busiest three regions before the next peak.",
},
{
key: "healthy",
label: "Healthy",
range: "70–100",
min: 70,
max: 100,
arcClass: "stroke-emerald-500",
dotClass: "bg-emerald-500",
textClass: "text-emerald-600 dark:text-emerald-400",
advice:
"All regions are inside the latency budget. No action required — keep an eye on the next deploy window.",
},
];
const PRESETS: Preset[] = [
{ label: "Off-peak", hint: "02:00 UTC", value: 88 },
{ label: "Business hours", hint: "14:00 UTC", value: 64 },
{ label: "Traffic spike", hint: "Flash sale", value: 33 },
];
const TAU = Math.PI / 180;
function point(angle: number, radius: number): { x: number; y: number } {
return {
x: CX + radius * Math.cos(angle * TAU),
y: CY - radius * Math.sin(angle * TAU),
};
}
function arcPath(v0: number, v1: number, radius: number): string {
const a0 = 180 - (v0 / 100) * 180;
const a1 = 180 - (v1 / 100) * 180;
const p0 = point(a0, radius);
const p1 = point(a1, radius);
const large = Math.abs(a1 - a0) > 180 ? 1 : 0;
return `M ${p0.x.toFixed(2)} ${p0.y.toFixed(2)} A ${radius} ${radius} 0 ${large} 1 ${p1.x.toFixed(2)} ${p1.y.toFixed(2)}`;
}
function zoneFor(value: number): Zone {
return ZONES.find((z) => value < z.max) ?? ZONES[ZONES.length - 1];
}
export default function ChartGauge() {
const reduce = useReducedMotion();
const sliderId = useId();
const [value, setValue] = useState(64);
const [shown, setShown] = useState(64);
const currentRef = useRef(64);
useEffect(() => {
const controls = animate(currentRef.current, value, {
duration: reduce ? 0 : 0.55,
ease: [0.22, 1, 0.36, 1],
onUpdate: (v: number) => {
currentRef.current = v;
setShown(v);
},
});
return () => controls.stop();
}, [value, reduce]);
const clamped = Math.max(0, Math.min(100, shown));
const shownZone = zoneFor(clamped);
const committedZone = zoneFor(value);
const dashOffset = 100 - clamped;
const needleAngle = (clamped / 100) * 180 - 180;
const latency = Math.round(340 - clamped * 2.6);
const margin = Math.round(clamped) - 70;
const trackPath = arcPath(0, 100, R);
return (
<section className="relative w-full bg-slate-50 px-5 py-16 sm:px-8 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes chartgauge-halo {
0%, 100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(2.1); opacity: 0; }
}
.chartgauge-halo { animation: chartgauge-halo 2.4s ease-in-out infinite; }
.chartgauge-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 1.125rem;
width: 1.125rem;
margin-top: -0.3125rem;
border-radius: 9999px;
background: var(--cg-thumb);
border: 2px solid var(--cg-accent);
box-shadow: 0 1px 5px rgba(15, 23, 42, 0.35);
cursor: pointer;
transition: transform 0.15s ease;
}
.chartgauge-range::-moz-range-thumb {
height: 1.125rem;
width: 1.125rem;
border-radius: 9999px;
background: var(--cg-thumb);
border: 2px solid var(--cg-accent);
box-shadow: 0 1px 5px rgba(15, 23, 42, 0.35);
cursor: pointer;
transition: transform 0.15s ease;
}
.chartgauge-range:active::-webkit-slider-thumb { transform: scale(1.12); }
.chartgauge-range:active::-moz-range-thumb { transform: scale(1.12); }
@media (prefers-reduced-motion: reduce) {
.chartgauge-halo { animation: none; }
.chartgauge-range::-webkit-slider-thumb,
.chartgauge-range::-moz-range-thumb { transition: none; }
.chartgauge-range:active::-webkit-slider-thumb,
.chartgauge-range:active::-moz-range-thumb { transform: none; }
}
`}</style>
<div className="mx-auto w-full max-w-2xl">
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-[0_1px_0_rgba(0,0,0,0.02),0_20px_50px_-24px_rgba(15,23,42,0.35)] dark:border-slate-800 dark:bg-slate-900">
<div className="p-6 sm:p-9">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">
<span className="relative flex h-2 w-2">
<span className={`chartgauge-halo absolute inset-0 rounded-full ${committedZone.dotClass}`} />
<span className={`relative inline-flex h-2 w-2 rounded-full ${committedZone.dotClass}`} />
</span>
Reliability · Live
</div>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
Edge Response Health
</h2>
<p className="mt-2 max-w-md text-sm leading-relaxed text-slate-600 dark:text-slate-400">
A rolling p95-latency score across all 14 edge regions, normalized to 100. Drag the
control or load a snapshot to see how each scenario scores against the SLA.
</p>
{/* Gauge */}
<div className="relative mx-auto mt-6 w-full max-w-[440px]">
<svg
viewBox="0 0 400 214"
className="w-full"
role="img"
aria-label={`Edge response health gauge showing ${value} out of 100, status ${committedZone.label}.`}
>
{/* Track */}
<path
d={trackPath}
fill="none"
strokeLinecap="round"
strokeWidth={SW}
className="stroke-slate-200 dark:stroke-slate-800"
/>
{/* Value fill */}
<path
d={trackPath}
fill="none"
strokeLinecap="round"
strokeWidth={SW}
pathLength={100}
strokeDasharray="100 100"
strokeDashoffset={dashOffset}
className={shownZone.arcClass}
/>
{/* Threshold dividers at 40 and 70 */}
{[40, 70].map((t) => {
const a = 180 - (t / 100) * 180;
const inner = point(a, R - SW / 2);
const outer = point(a, R + SW / 2);
return (
<line
key={t}
x1={inner.x}
y1={inner.y}
x2={outer.x}
y2={outer.y}
strokeWidth={3}
className="stroke-white dark:stroke-slate-900"
/>
);
})}
{/* Ticks + labels */}
{TICKS.map((t) => {
const a = 180 - (t / 100) * 180;
const p1 = point(a, R + SW / 2 + 4);
const p2 = point(a, R + SW / 2 + 11);
const pl = point(a, R + SW / 2 + 24);
return (
<g key={t}>
<line
x1={p1.x}
y1={p1.y}
x2={p2.x}
y2={p2.y}
strokeWidth={2}
strokeLinecap="round"
className="stroke-slate-300 dark:stroke-slate-700"
/>
<text
x={pl.x}
y={pl.y}
textAnchor="middle"
dominantBaseline="middle"
className="fill-slate-400 text-[11px] font-medium tabular-nums dark:fill-slate-500"
>
{t}
</text>
</g>
);
})}
{/* Needle */}
<g transform={`rotate(${needleAngle.toFixed(3)} ${CX} ${CY})`}>
<polygon
points={`${CX - 16},${CY} ${CX},${CY - 5} ${CX + 122},${CY} ${CX},${CY + 5}`}
className="fill-slate-700 dark:fill-slate-200"
/>
</g>
<circle cx={CX} cy={CY} r={11} className="fill-slate-800 dark:fill-slate-100" />
<circle cx={CX} cy={CY} r={4.5} className="fill-white dark:fill-slate-900" />
</svg>
{/* Center readout */}
<div className="pointer-events-none absolute left-1/2 top-[54%] -translate-x-1/2 -translate-y-1/2 text-center">
<div className="flex items-baseline justify-center gap-1">
<span className="text-5xl font-semibold tabular-nums tracking-tight text-slate-900 sm:text-6xl dark:text-slate-50">
{Math.round(clamped)}
</span>
<span className="text-lg font-medium text-slate-400 dark:text-slate-500">/100</span>
</div>
<div className="mt-2 inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white/80 px-2.5 py-1 text-xs font-semibold backdrop-blur dark:border-slate-700 dark:bg-slate-800/80">
<span className={`h-1.5 w-1.5 rounded-full ${committedZone.dotClass}`} />
<span className={committedZone.textClass}>{committedZone.label}</span>
</div>
</div>
</div>
{/* Legend */}
<div className="mt-4 grid grid-cols-3 gap-2">
{ZONES.map((z) => {
const active = z.key === committedZone.key;
return (
<div
key={z.key}
className={`flex items-center gap-2 rounded-xl border px-2.5 py-2 transition-colors ${
active
? "border-slate-300 bg-slate-50 dark:border-slate-600 dark:bg-slate-800"
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
}`}
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${z.dotClass}`} />
<span className="min-w-0">
<span className="block truncate text-xs font-semibold text-slate-700 dark:text-slate-200">
{z.label}
</span>
<span className="block text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
{z.range}
</span>
</span>
</div>
);
})}
</div>
{/* Advice */}
<p
aria-live="polite"
className="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-relaxed text-slate-600 dark:border-slate-800 dark:bg-slate-950/40 dark:text-slate-400"
>
<span className={`font-semibold ${committedZone.textClass}`}>{committedZone.label}.</span>{" "}
{committedZone.advice}
</p>
{/* Derived stats */}
<div className="mt-5 grid grid-cols-2 gap-3">
<div className="rounded-xl border border-slate-200 bg-white p-3.5 dark:border-slate-800 dark:bg-slate-900">
<div className="text-[11px] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
Estimated p95 latency
</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-slate-900 dark:text-slate-50">
{latency}
<span className="ml-1 text-sm font-normal text-slate-400 dark:text-slate-500">ms</span>
</div>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-3.5 dark:border-slate-800 dark:bg-slate-900">
<div className="text-[11px] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
Margin vs SLA (70)
</div>
<div
className={`mt-1 text-xl font-semibold tabular-nums ${
margin >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400"
}`}
>
{margin >= 0 ? "+" : ""}
{margin}
<span className="ml-1 text-sm font-normal text-slate-400 dark:text-slate-500">pts</span>
</div>
</div>
</div>
{/* Slider */}
<div className="mt-6">
<div className="flex items-center justify-between">
<label
htmlFor={sliderId}
className="text-sm font-medium text-slate-700 dark:text-slate-300"
>
Model a scenario
</label>
<span className="text-sm tabular-nums font-semibold text-slate-500 dark:text-slate-400">
{value}
</span>
</div>
<input
id={sliderId}
type="range"
min={0}
max={100}
step={1}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => setValue(Number(e.target.value))}
aria-valuetext={`${value} out of 100, ${committedZone.label}`}
className="chartgauge-range mt-2 h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 outline-none [--cg-accent:#4f46e5] [--cg-thumb:#ffffff] focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-800 dark:[--cg-accent:#818cf8] dark:[--cg-thumb:#f1f5f9] dark:focus-visible:ring-offset-slate-900"
/>
</div>
{/* Presets */}
<div className="mt-5">
<div className="text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
Load a snapshot
</div>
<div className="mt-2 flex flex-wrap gap-2" role="group" aria-label="Scenario snapshots">
{PRESETS.map((preset) => {
const active = value === preset.value;
return (
<button
key={preset.label}
type="button"
onClick={() => setValue(preset.value)}
aria-pressed={active}
className={`group inline-flex flex-col items-start rounded-xl border px-3.5 py-2 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
active
? "border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-500/10"
: "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700 dark:hover:bg-slate-800"
}`}
>
<span
className={`text-sm font-semibold ${
active
? "text-indigo-700 dark:text-indigo-300"
: "text-slate-700 dark:text-slate-200"
}`}
>
{preset.label}
</span>
<span className="text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
{preset.hint} · scores {preset.value}
</span>
</button>
);
})}
</div>
</div>
</div>
</div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Chart Bar
Originalhand-built SVG/div bar chart with axes

Chart Bar Grouped
Originalgrouped bar chart with legend

Chart Line
OriginalSVG line chart with points and grid

Chart Area
OriginalSVG area chart with gradient fill

Chart Donut
OriginalSVG donut chart with centre label

Chart Pie
OriginalSVG pie chart with legend

Chart Radial
Originalradial progress bars chart

Chart Sparkline
Originalinline sparklines in stat rows

Chart Stacked Bar
Originalstacked bar chart

Chart Progress
Originalhorizontal progress/metric chart

Chart Heatmap
Originalcontribution-style heatmap grid

Chart Bubble
OriginalSVG bubble/scatter chart

