Web InnoventixFreeCode

Chart Gauge

Original · free

semicircle gauge chart

byWeb InnoventixReact + Tailwind
chartgaugecharts
Open

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.json
chart-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 quote

Similar components

Browse all →