Animated Gauge Chart Score Card with Open Arc

A report card built from gauge charts. Each gauge is an SVG ring with an opening at the bottom; a coloured arc sweeps round to the score while the number in the middle counts up beside it. One large gauge shows the overall result and four small ones break it down, and every gauge turns green, amber or red by its value with a matching word, so colour is never the only signal. The button loads another set of results and all five gauges move from their old values to the new ones. On phones the large gauge sits above a two by two grid of small ones.

Code

// Adapted from Animata (https://animata.design/docs/graphs/gauge-chart)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState, type ReactNode } from "react";
import { RefreshCw } from "lucide-react";

const audits = [
  { key: "speed", label: "Speed", note: "Largest element painted in 1.4 s" },
  { key: "access", label: "Accessibility", note: "2 images are missing a description" },
  { key: "practice", label: "Best practices", note: "All requests use a secure connection" },
  { key: "search", label: "Search", note: "5 pages have no meta description" },
];

// Each run of the check shows one of these result sets.
const runs = [
  { overall: 92, scores: [96, 88, 100, 74] },
  { overall: 68, scores: [54, 81, 92, 47] },
  { overall: 85, scores: [79, 93, 83, 88] },
];

const tone = (value: number) =>
  value >= 90
    ? { stroke: "#059669", text: "text-emerald-700", word: "Good" }
    : value >= 60
      ? { stroke: "#d97706", text: "text-amber-700", word: "Needs work" }
      : { stroke: "#e11d48", text: "text-rose-700", word: "Poor" };

type GaugeProps = {
  /** 0 to 100 */
  progress: number;
  size: number;
  /** Length of the opening at the bottom of the ring, in pixels along the circle. */
  gap: number;
  width?: number;
  color: string;
  label: string;
  children?: ReactNode;
};

/** A ring with an opening at the bottom. The coloured arc eases to `progress`. */
function Gauge({ progress, size, gap, width = 14, color, label, children }: GaugeProps) {
  const radius = size / 2 - width;
  const circumference = Math.PI * radius * 2;
  const value = Math.max(0, Math.min(100, progress));
  const offset = circumference - (value / 100) * (circumference - gap);
  // Turn the ring so the opening is centred at the bottom.
  const rotate = `rotate(${90 + (gap / (2 * circumference)) * 360} ${size / 2} ${size / 2})`;
  return (
    <div className="relative" style={{ width: size, height: size }}>
      <svg role="img" aria-label={`${label}: ${Math.round(value)} out of 100`} width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
        <circle r={radius} cx={size / 2} cy={size / 2} fill="none" stroke="#e2e8f0" strokeWidth={width} strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={gap} transform={rotate} />
        <circle
          r={radius}
          cx={size / 2}
          cy={size / 2}
          fill="none"
          stroke={color}
          strokeWidth={width}
          strokeLinecap="round"
          strokeDasharray={circumference}
          strokeDashoffset={offset}
          transform={rotate}
          className="motion-reduce:transition-none"
          style={{ transition: "stroke-dashoffset 1100ms cubic-bezier(0.22, 1, 0.36, 1), stroke 400ms ease" }}
        />
      </svg>
      <div className="absolute inset-0 flex flex-col items-center justify-center">{children}</div>
    </div>
  );
}

/** Counts from the previous number to the next one. */
function useCountTo(target: number, duration = 1100) {
  const [shown, setShown] = useState(0);
  const from = useRef(0);
  useEffect(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setShown(target);
      from.current = target;
      return;
    }
    const start = performance.now();
    const begin = from.current;
    let raf = 0;
    const step = (now: number) => {
      const t = Math.min(1, (now - start) / duration);
      const eased = 1 - Math.pow(1 - t, 4);
      const value = Math.round(begin + (target - begin) * eased);
      from.current = value;
      setShown(value);
      if (t < 1) raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [target, duration]);
  return shown;
}

function Count({ value, className }: { value: number; className: string }) {
  const shown = useCountTo(value);
  return <span className={`tabular-nums ${className}`}>{shown}</span>;
}

export default function AnimatedGaugeChartScoreCard() {
  const [run, setRun] = useState(0);
  const [ready, setReady] = useState(false);
  const result = runs[run % runs.length];

  // Start empty, then fill shortly after load so the arcs sweep in.
  useEffect(() => {
    const timer = setTimeout(() => setReady(true), 250);
    return () => clearTimeout(timer);
  }, []);

  const overall = ready ? result.overall : 0;
  const main = tone(result.overall);

  return (
    <section className="bg-slate-100 px-4 py-14 sm:px-6">
      <div className="mx-auto max-w-4xl rounded-3xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-10">
        <div className="flex flex-wrap items-start justify-between gap-4">
          <div>
            <p className="text-xs font-semibold uppercase tracking-widest text-slate-500">Site health report</p>
            <h2 className="mt-1 text-2xl font-semibold tracking-tight text-slate-900">fernandbloom.example</h2>
            <p className="mt-1 text-sm text-slate-600">Checked on a mid-range phone over a 4G connection.</p>
          </div>
          <button
            type="button"
            onClick={() => setRun((value) => value + 1)}
            className="inline-flex h-11 items-center gap-2 rounded-full bg-slate-900 px-5 text-sm font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2"
          >
            <RefreshCw aria-hidden="true" className="h-4 w-4" />
            Run check again
          </button>
        </div>

        <div className="mt-8 grid items-center gap-10 md:grid-cols-5">
          <div className="flex flex-col items-center md:col-span-2">
            <Gauge progress={overall} size={232} gap={150} width={20} color={main.stroke} label="Overall score">
              <Count value={overall} className="text-6xl font-semibold tracking-tight text-slate-900" />
              <span className={`mt-1 text-sm font-semibold ${main.text}`}>{main.word}</span>
            </Gauge>
            <p className="-mt-6 flex w-48 justify-between text-xs font-medium tabular-nums text-slate-500" aria-hidden="true">
              <span>0</span>
              <span>100</span>
            </p>
          </div>

          <ul className="grid grid-cols-2 gap-x-6 gap-y-8 md:col-span-3">
            {audits.map((audit, i) => {
              const score = ready ? result.scores[i] : 0;
              const t = tone(result.scores[i]);
              return (
                <li key={audit.key} className="flex flex-col items-center gap-3 text-center sm:flex-row sm:text-left">
                  <Gauge progress={score} size={92} gap={56} width={9} color={t.stroke} label={audit.label}>
                    <Count value={score} className="text-xl font-semibold text-slate-900" />
                  </Gauge>
                  <div className="min-w-0">
                    <p className="text-sm font-semibold text-slate-900">{audit.label}</p>
                    <p className={`text-xs font-semibold ${t.text}`}>{t.word}</p>
                    <p className="mt-1 hidden text-xs leading-snug text-slate-600 sm:block">{audit.note}</p>
                  </div>
                </li>
              );
            })}
          </ul>
        </div>

        <dl className="mt-10 flex flex-wrap gap-x-6 gap-y-2 border-t border-slate-200 pt-5 text-xs text-slate-600">
          {[
            { color: "bg-emerald-600", range: "90 to 100", word: "Good" },
            { color: "bg-amber-600", range: "60 to 89", word: "Needs work" },
            { color: "bg-rose-600", range: "0 to 59", word: "Poor" },
          ].map((row) => (
            <div key={row.word} className="flex items-center gap-2">
              <span aria-hidden="true" className={`h-2.5 w-2.5 rounded-full ${row.color}`} />
              <dt className="font-semibold text-slate-800">{row.word}</dt>
              <dd className="tabular-nums">{row.range}</dd>
            </div>
          ))}
        </dl>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/animated-gauge-chart-score-card-with-open-arc.json

When to use it

How to customize it

Accessibility

Questions

How is the gauge drawn?

It is two SVG circles with a dash pattern as long as the circumference. Offsetting the dash hides part of the stroke: the track hides only the opening, and the coloured arc hides everything past the score. A rotation centres the opening at the bottom.

What does the gap value mean?

It is the length of the missing part of the ring in pixels, measured along the circle. On the 232 pixel gauge the circumference is about 603 pixels, so a gap of 150 removes roughly a quarter of the ring.

Why does the arc animate without a motion library?

The arc length is the `stroke-dashoffset` property, which browsers can transition in CSS. Changing the `progress` value changes the offset and the transition does the rest.

Can I show a value that is not out of 100?

Yes. Convert it to a percentage for `progress`, for example used divided by limit times 100, and print the real figure in the centre by passing your own children to `Gauge`.

How do I make a half circle gauge?

Set `gap` to half the circumference, which is `Math.PI * radius`. The rotation is calculated from the gap, so the opening stays centred at the bottom and you can crop the lower half with a wrapper that has `overflow-hidden`.

Adapted from Gauge Chart in Animata, used under its open-source licence. Licences and credits

Related: Stats Section with Radial Progress Rings, Activity Rings Card with Day Switcher, Stat Tiles with Sparkline Trends.

More dashboard or all free UI components. Start for free to have a full site built around it.

More in Dashboard

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap