Gauge rings

Three ring gauges sweep to their values with the numbers counting up alongside.

How it works

Each gauge is an SVG circle with stroke-dasharray set to its circumference. When the row becomes visible, a short eased animation moves stroke-dashoffset to the target share and writes the matching percentage in the middle on every frame.

Full file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gauge rings</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; }

body { background: #14213d; color: #fff; }
.pad { height: 70vh; display: grid; place-content: center; }
.pad h1 { font-size: 2.6rem; margin: 0; }
.gauges { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4vw; padding: 0 6vw; max-width: 1000px; margin: 0 auto; }
.gauges figure { margin: 0; position: relative; text-align: center; }
.gauges svg { width: 100%; transform: rotate(-90deg); }
.gauges circle { fill: none; stroke-width: 12; }
.gauges .bg { stroke: rgba(255,255,255,.1); }
.gauges .fg { stroke: var(--c); stroke-linecap: round; stroke-dasharray: 314.16; stroke-dashoffset: 314.16; }
.gauges b { position: absolute; top: 0; left: 0; right: 0; aspect-ratio: 1; display: grid; place-items: center; font: 800 clamp(1.4rem, 5vw, 2.6rem) system-ui, sans-serif; }
.gauges figcaption { margin-top: 12px; opacity: .75; }
@media (max-width: 560px) { .gauges { grid-template-columns: 1fr; padding: 0 22vw; } }
</style>
</head>
<body>
<div class="pad"><h1>Gauge rings</h1></div>
<section class="gauges" id="gauges">
  <figure data-v="82" style="--c:#ff595e"><svg viewBox="0 0 120 120"><circle class="bg" cx="60" cy="60" r="50"/><circle class="fg" cx="60" cy="60" r="50"/></svg><b>0%</b><figcaption>Read to the end</figcaption></figure>
  <figure data-v="64" style="--c:#ffca3a"><svg viewBox="0 0 120 120"><circle class="bg" cx="60" cy="60" r="50"/><circle class="fg" cx="60" cy="60" r="50"/></svg><b>0%</b><figcaption>Scrolled on mobile</figcaption></figure>
  <figure data-v="37" style="--c:#1982c4"><svg viewBox="0 0 120 120"><circle class="bg" cx="60" cy="60" r="50"/><circle class="fg" cx="60" cy="60" r="50"/></svg><b>0%</b><figcaption>Came back again</figcaption></figure>
</section>
<div class="pad"></div>
<script>
const C = 2 * Math.PI * 50;
const ease = t => 1 - Math.pow(1 - t, 3);
const figs = [...document.querySelectorAll('.gauges figure')];
function run(f, on) {
  const fg = f.querySelector('.fg'), label = f.querySelector('b'), v = +f.dataset.v;
  cancelAnimationFrame(f._raf);
  if (!on) { fg.style.strokeDashoffset = C; label.textContent = '0%'; return; }
  const start = performance.now();
  const tick = now => {
    const t = Math.min(1, (now - start) / 1400), k = ease(t) * v;
    fg.style.strokeDashoffset = (C * (1 - k / 100)).toFixed(2);
    label.textContent = Math.round(k) + '%';
    if (t < 1) f._raf = requestAnimationFrame(tick);
  };
  f._raf = requestAnimationFrame(tick);
}
new IntersectionObserver(([e]) => figs.forEach((f, i) => setTimeout(() => run(f, e.isIntersecting), e.isIntersecting ? i * 150 : 0)), { threshold: 0.5 })
  .observe(document.getElementById('gauges'));
</script>
</body>
</html>

More data effects