Activity Rings — Concentric Progress Rings HTML CSS

Activity Rings · Dashboards · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Concentric nested rings
Each ring's radius steps inward by stroke + gap, so any number of rings nest evenly from one generation loop.
stroke-dashoffset goal fill
Each arc fills to exactly its goal fraction via the dasharray/dashoffset technique, clamped so over-goals fill fully.
Draw-on animation
Rings animate from empty to filled on load via a transition, using double-rAF so the animation actually runs.
Top-start arcs
A −90° rotation starts each ring filling from 12 o'clock, the expected orientation.
Rounded chunky strokes
stroke-linecap: round gives the signature thick, rounded-end ring look.
Value/goal legend
A color-matched legend shows each ring's value over goal with units, percent, and a ✓ when complete.
True percent over 100%
Exceeded goals fill the ring fully but the legend reports the real percentage, like Apple's rings.
Fully data-driven
Name, value, goal, unit, and color come from the RINGS array — adapt to any nested-goal set with a data edit.

About this UI Snippet

Activity Rings — Apple-Style Concentric Goal Rings in SVG

Screenshot of the Activity Rings snippet rendered live

Apple's activity rings — three nested, brightly colored arcs that fill as you close each daily goal — are one of the most recognizable data visualizations ever shipped, because they pack three progress metrics into one glanceable, motivating graphic. This snippet recreates that concentric-rings pattern in plain HTML, CSS, and SVG: any number of rings, each animating from empty to its goal percentage, with a matching value/goal legend.

Nested rings from one loop

Each ring is a pair of SVG circles — a faint full-circle track and a colored progress arc — sharing a center but with a decreasing radius so they nest inside one another. The radius for ring i steps inward by the stroke width plus a gap (CENTER − STROKE/2 − 4 − i × (STROKE + GAP)), so the rings sit concentrically with even spacing regardless of how many you add. Thick stroke-linecap: round strokes give the signature chunky, rounded-end look. It's all generated in a single loop over the ring data, so three rings or five is the same code.

The fill is stroke-dashoffset, capped at 100%

Each progress arc uses the stroke-dasharray / stroke-dashoffset technique: the dasharray is set to the circle's circumference, and the dashoffset starts at the full circumference (empty) and animates to circumference × (1 − percent) so the arc fills to exactly the goal fraction. The percent is clamped to 1, so an over-achieved goal (like 38 of 30 exercise minutes in the demo) fills the ring completely rather than overflowing — and the legend still shows the true 127%, the way Apple's rings do. The arcs are rotated −90° so they start filling from the top (12 o'clock) rather than 3 o'clock.

Animation that draws the rings on

On load, each arc starts empty and animates to its filled offset via a CSS transition with a smooth ease, using the double-requestAnimationFrame pattern to ensure the browser registers the starting state before the transition runs (otherwise it would jump straight to filled with no animation). The rings sweep into place over a second, which is both satisfying and draws the eye to each metric's progress — the motion is the moment that makes the rings feel alive rather than static.

A legend that names the numbers

Rings alone are evocative but ambiguous, so a legend lists each ring's name, current value over goal with units ("520/600 cal"), and percentage, color-matched to its arc, with a ✓ when a goal is met. This pairs the at-a-glance ring graphic with the precise numbers, covering both the "how am I doing overall" and "exactly what are my numbers" questions. Color is the link between a ring and its legend row.

Fully data-driven and re-themeable

Everything comes from the RINGS array — name, value, goal, unit, color — so adapting this to any three (or more) goals is a data edit. Apple uses Move/Exercise/Stand, but the same component works for any nested-goal display: daily tasks done, calories/protein/water, or three KPIs against targets. Swap the colors and labels and it fits any brand or metric set.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSAn activity-rings card renders on a dark background; the three rings animate from empty to their goal progress.
  2. 2
    Read the ringsEach concentric ring fills toward its goal — Move, Exercise, Stand — in its own color, starting from the top.
  3. 3
    Check the legendThe side legend shows each ring's value/goal with units and percentage, color-matched, with a ✓ when met.
  4. 4
    Note over-achievementA goal exceeded (38/30 min) fills its ring completely while the legend shows the true 127%.
  5. 5
    Edit the goalsChange the RINGS array (name, value, goal, unit, color) — radii, animation, and legend all recompute.
  6. 6
    Connect real dataPopulate RINGS from your fitness, habit, or goal data and re-run build() to draw the day's progress.

Real-world uses

Common Use Cases

Fitness and health apps
Daily activity, step, or workout goals in the Apple-rings style — pair with a streak tracker for consistency.
Habit and goal tracking
Show progress toward three daily habits at a glance, alongside a profile completion ring.
Productivity dashboards
Visualize tasks done, focus time, and breaks taken against targets.
Nutrition and wellness
Track calories, protein, and water as three goal rings.
KPI goal attainment
Display three business metrics against their targets in one compact graphic, beside stat comparison cards.
Learning SVG ring techniques
A reference for concentric rings and animated dashoffset fills — compare with an SVG progress ring for a single ring.

Got questions?

Frequently Asked Questions

Edit the RINGS array — each entry has a name, value, goal, unit, and color. The build loop computes each ring's radius from its index, so adding a fourth ring nests it inside automatically; just give it a distinct color. The legend regenerates from the same array, so values and percentages update with no other changes.

Each progress arc's stroke-dasharray is set to the circle's circumference, turning the stroke into one dash as long as the ring. The stroke-dashoffset starts at the full circumference (fully hidden) and animates to circumference × (1 − percent), revealing exactly the goal fraction. The percent is clamped to 1 so an exceeded goal fills the ring completely rather than wrapping past the start.

CSS transitions only animate when a property changes after the element is rendered with its initial value. Setting dashoffset to empty and immediately to filled in the same frame would skip the animation. Two nested requestAnimationFrame calls let the browser paint the empty state first, so the subsequent change to the filled offset transitions smoothly.

Clamp only the visual fill (Math.min(1, value/goal) for the dashoffset) but compute the legend's percentage from the raw value/goal — so the ring caps at full while the number reads 127%. This mirrors Apple's behavior where the ring completes but the count keeps going, signaling over-achievement honestly.

In React, map the RINGS array to <circle> elements in JSX, computing each radius and dashoffset with useMemo, and trigger the animation with a mounted state or key; in Vue, use v-for with computed geometry; in Angular, use *ngFor. The radius/circumference/dashoffset math is plain SVG that ports unchanged — only the mount-time animation trigger uses each framework's lifecycle.

Activity Rings — Export as HTML, React, Vue, Angular & Tailwind

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Activity Rings</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#0b1120;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
    
    .arg-card{background:#111827;border:1px solid #1f2937;border-radius:20px;padding:24px;width:100%;max-width:380px;box-shadow:0 18px 44px rgba(0,0,0,.45)}
    .arg-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px}
    .arg-head h3{font-size:16px;font-weight:800;color:#f8fafc}
    .arg-date{font-size:12px;color:#6b7280;font-weight:600}
    
    .arg-main{display:flex;align-items:center;gap:22px;margin-top:14px}
    .arg-rings{width:160px;height:160px;flex-shrink:0}
    .arg-track{fill:none;stroke-linecap:round}
    .arg-prog{fill:none;stroke-linecap:round;transition:stroke-dashoffset 1s cubic-bezier(.22,1,.36,1)}
    
    .arg-legend{display:flex;flex-direction:column;gap:13px}
    .arg-row{display:flex;flex-direction:column;gap:2px}
    .arg-row-top{display:flex;align-items:center;gap:7px}
    .arg-dot{width:9px;height:9px;border-radius:50%}
    .arg-name{font-size:12px;font-weight:700;color:#e5e7eb}
    .arg-vals{font-size:16px;font-weight:800;color:#f8fafc;font-variant-numeric:tabular-nums;padding-left:16px}
    .arg-vals small{font-size:11px;font-weight:600;color:#6b7280}
    .arg-pct{font-size:10.5px;font-weight:700;padding-left:16px}
  </style>
</head>
<body>
  <div class="arg-card">
    <div class="arg-head">
      <h3>Today's activity</h3>
      <span class="arg-date">Mon, Jun 22</span>
    </div>
  
    <div class="arg-main">
      <svg class="arg-rings" viewBox="0 0 200 200" id="argRings"></svg>
  
      <div class="arg-legend" id="argLegend"></div>
    </div>
  </div>
  <script>
    var RINGS = [
      { name: 'Move',    value: 520, goal: 600, unit: 'cal', color: '#fb2c5a' },
      { name: 'Exercise',value: 38,  goal: 30,  unit: 'min', color: '#a3f307' },
      { name: 'Stand',   value: 9,   goal: 12,  unit: 'hr',  color: '#22d3ee' },
    ];
    var SIZE = 200, CENTER = 100, GAP = 8, STROKE = 17;
    var svg = document.getElementById('argRings');
    var NS = 'http://www.w3.org/2000/svg';
    
    function build() {
      RINGS.forEach(function (ring, i) {
        var r = CENTER - STROKE / 2 - 4 - i * (STROKE + GAP);
        var circ = 2 * Math.PI * r;
        var pct = Math.min(1, ring.value / ring.goal);
    
        var track = document.createElementNS(NS, 'circle');
        track.setAttribute('class', 'arg-track');
        track.setAttribute('cx', CENTER); track.setAttribute('cy', CENTER); track.setAttribute('r', r);
        track.setAttribute('stroke', ring.color); track.setAttribute('stroke-width', STROKE);
        track.setAttribute('opacity', '0.18');
        svg.appendChild(track);
    
        var prog = document.createElementNS(NS, 'circle');
        prog.setAttribute('class', 'arg-prog');
        prog.setAttribute('cx', CENTER); prog.setAttribute('cy', CENTER); prog.setAttribute('r', r);
        prog.setAttribute('stroke', ring.color); prog.setAttribute('stroke-width', STROKE);
        prog.setAttribute('transform', 'rotate(-90 ' + CENTER + ' ' + CENTER + ')');
        prog.setAttribute('stroke-dasharray', circ);
        prog.setAttribute('stroke-dashoffset', circ);   // start empty, animate to filled
        svg.appendChild(prog);
    
        // Animate after a frame so the transition runs.
        requestAnimationFrame(function () {
          requestAnimationFrame(function () { prog.setAttribute('stroke-dashoffset', circ * (1 - pct)); });
        });
      });
    
      document.getElementById('argLegend').innerHTML = RINGS.map(function (ring) {
        var pct = Math.round((ring.value / ring.goal) * 100);
        return '<div class="arg-row">' +
          '<div class="arg-row-top"><span class="arg-dot" style="background:' + ring.color + '"></span><span class="arg-name">' + ring.name + '</span></div>' +
          '<span class="arg-vals">' + ring.value + '<small>/' + ring.goal + ' ' + ring.unit + '</small></span>' +
          '<span class="arg-pct" style="color:' + ring.color + '">' + pct + '%' + (pct >= 100 ? ' ✓' : '') + '</span>' +
        '</div>';
      }).join('');
    }
    
    build();
  </script>
</body>
</html>
Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Activity Rings</title>
  <!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    * {
      box-sizing:border-box;margin:0;padding:0
    }

    body {
      font-family:system-ui,-apple-system,sans-serif;background:#0b1120;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px
    }

    .arg-head h3 {
      font-size:16px;font-weight:800;color:#f8fafc
    }

    .arg-track {
      fill:none;stroke-linecap:round
    }

    .arg-prog {
      fill:none;stroke-linecap:round;transition:stroke-dashoffset 1s cubic-bezier(.22,1,.36,1)
    }

    .arg-row {
      display:flex;flex-direction:column;gap:2px
    }

    .arg-row-top {
      display:flex;align-items:center;gap:7px
    }

    .arg-dot {
      width:9px;height:9px;border-radius:50%
    }

    .arg-name {
      font-size:12px;font-weight:700;color:#e5e7eb
    }

    .arg-vals {
      font-size:16px;font-weight:800;color:#f8fafc;font-variant-numeric:tabular-nums;padding-left:16px
    }

    .arg-vals small {
      font-size:11px;font-weight:600;color:#6b7280
    }

    .arg-pct {
      font-size:10.5px;font-weight:700;padding-left:16px
    }
  </style>
</head>
<body>
  <div class="bg-[#111827] border border-[#1f2937] rounded-[20px] p-6 w-full max-w-[380px] shadow-[0_18px_44px_rgba(0,0,0,.45)]">
    <div class="arg-head flex items-baseline justify-between mb-1.5">
      <h3>Today's activity</h3>
      <span class="text-xs text-[#6b7280] font-semibold">Mon, Jun 22</span>
    </div>
  
    <div class="flex items-center gap-[22px] mt-3.5">
      <svg class="w-40 h-40 shrink-0" viewBox="0 0 200 200" id="argRings"></svg>
  
      <div class="flex flex-col gap-[13px]" id="argLegend"></div>
    </div>
  </div>
  <script>
    var RINGS = [
      { name: 'Move',    value: 520, goal: 600, unit: 'cal', color: '#fb2c5a' },
      { name: 'Exercise',value: 38,  goal: 30,  unit: 'min', color: '#a3f307' },
      { name: 'Stand',   value: 9,   goal: 12,  unit: 'hr',  color: '#22d3ee' },
    ];
    var SIZE = 200, CENTER = 100, GAP = 8, STROKE = 17;
    var svg = document.getElementById('argRings');
    var NS = 'http://www.w3.org/2000/svg';
    
    function build() {
      RINGS.forEach(function (ring, i) {
        var r = CENTER - STROKE / 2 - 4 - i * (STROKE + GAP);
        var circ = 2 * Math.PI * r;
        var pct = Math.min(1, ring.value / ring.goal);
    
        var track = document.createElementNS(NS, 'circle');
        track.setAttribute('class', 'arg-track');
        track.setAttribute('cx', CENTER); track.setAttribute('cy', CENTER); track.setAttribute('r', r);
        track.setAttribute('stroke', ring.color); track.setAttribute('stroke-width', STROKE);
        track.setAttribute('opacity', '0.18');
        svg.appendChild(track);
    
        var prog = document.createElementNS(NS, 'circle');
        prog.setAttribute('class', 'arg-prog');
        prog.setAttribute('cx', CENTER); prog.setAttribute('cy', CENTER); prog.setAttribute('r', r);
        prog.setAttribute('stroke', ring.color); prog.setAttribute('stroke-width', STROKE);
        prog.setAttribute('transform', 'rotate(-90 ' + CENTER + ' ' + CENTER + ')');
        prog.setAttribute('stroke-dasharray', circ);
        prog.setAttribute('stroke-dashoffset', circ);   // start empty, animate to filled
        svg.appendChild(prog);
    
        // Animate after a frame so the transition runs.
        requestAnimationFrame(function () {
          requestAnimationFrame(function () { prog.setAttribute('stroke-dashoffset', circ * (1 - pct)); });
        });
      });
    
      document.getElementById('argLegend').innerHTML = RINGS.map(function (ring) {
        var pct = Math.round((ring.value / ring.goal) * 100);
        return '<div class="arg-row">' +
          '<div class="arg-row-top"><span class="arg-dot" style="background:' + ring.color + '"></span><span class="arg-name">' + ring.name + '</span></div>' +
          '<span class="arg-vals">' + ring.value + '<small>/' + ring.goal + ' ' + ring.unit + '</small></span>' +
          '<span class="arg-pct" style="color:' + ring.color + '">' + pct + '%' + (pct >= 100 ? ' ✓' : '') + '</span>' +
        '</div>';
      }).join('');
    }
    
    build();
  </script>
</body>
</html>
React
import React, { useEffect } from 'react';

// CSS — optionally move to ActivityRings.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0b1120;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}

.arg-card{background:#111827;border:1px solid #1f2937;border-radius:20px;padding:24px;width:100%;max-width:380px;box-shadow:0 18px 44px rgba(0,0,0,.45)}
.arg-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px}
.arg-head h3{font-size:16px;font-weight:800;color:#f8fafc}
.arg-date{font-size:12px;color:#6b7280;font-weight:600}

.arg-main{display:flex;align-items:center;gap:22px;margin-top:14px}
.arg-rings{width:160px;height:160px;flex-shrink:0}
.arg-track{fill:none;stroke-linecap:round}
.arg-prog{fill:none;stroke-linecap:round;transition:stroke-dashoffset 1s cubic-bezier(.22,1,.36,1)}

.arg-legend{display:flex;flex-direction:column;gap:13px}
.arg-row{display:flex;flex-direction:column;gap:2px}
.arg-row-top{display:flex;align-items:center;gap:7px}
.arg-dot{width:9px;height:9px;border-radius:50%}
.arg-name{font-size:12px;font-weight:700;color:#e5e7eb}
.arg-vals{font-size:16px;font-weight:800;color:#f8fafc;font-variant-numeric:tabular-nums;padding-left:16px}
.arg-vals small{font-size:11px;font-weight:600;color:#6b7280}
.arg-pct{font-size:10.5px;font-weight:700;padding-left:16px}
`;

export default function ActivityRings() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var RINGS = [
      { name: 'Move',    value: 520, goal: 600, unit: 'cal', color: '#fb2c5a' },
      { name: 'Exercise',value: 38,  goal: 30,  unit: 'min', color: '#a3f307' },
      { name: 'Stand',   value: 9,   goal: 12,  unit: 'hr',  color: '#22d3ee' },
    ];
    var SIZE = 200, CENTER = 100, GAP = 8, STROKE = 17;
    var svg = document.getElementById('argRings');
    var NS = 'http://www.w3.org/2000/svg';
    
    function build() {
      RINGS.forEach(function (ring, i) {
        var r = CENTER - STROKE / 2 - 4 - i * (STROKE + GAP);
        var circ = 2 * Math.PI * r;
        var pct = Math.min(1, ring.value / ring.goal);
    
        var track = document.createElementNS(NS, 'circle');
        track.setAttribute('class', 'arg-track');
        track.setAttribute('cx', CENTER); track.setAttribute('cy', CENTER); track.setAttribute('r', r);
        track.setAttribute('stroke', ring.color); track.setAttribute('stroke-width', STROKE);
        track.setAttribute('opacity', '0.18');
        svg.appendChild(track);
    
        var prog = document.createElementNS(NS, 'circle');
        prog.setAttribute('class', 'arg-prog');
        prog.setAttribute('cx', CENTER); prog.setAttribute('cy', CENTER); prog.setAttribute('r', r);
        prog.setAttribute('stroke', ring.color); prog.setAttribute('stroke-width', STROKE);
        prog.setAttribute('transform', 'rotate(-90 ' + CENTER + ' ' + CENTER + ')');
        prog.setAttribute('stroke-dasharray', circ);
        prog.setAttribute('stroke-dashoffset', circ);   // start empty, animate to filled
        svg.appendChild(prog);
    
        // Animate after a frame so the transition runs.
        requestAnimationFrame(function () {
          requestAnimationFrame(function () { prog.setAttribute('stroke-dashoffset', circ * (1 - pct)); });
        });
      });
    
      document.getElementById('argLegend').innerHTML = RINGS.map(function (ring) {
        var pct = Math.round((ring.value / ring.goal) * 100);
        return '<div class="arg-row">' +
          '<div class="arg-row-top"><span class="arg-dot" style="background:' + ring.color + '"></span><span class="arg-name">' + ring.name + '</span></div>' +
          '<span class="arg-vals">' + ring.value + '<small>/' + ring.goal + ' ' + ring.unit + '</small></span>' +
          '<span class="arg-pct" style="color:' + ring.color + '">' + pct + '%' + (pct >= 100 ? ' ✓' : '') + '</span>' +
        '</div>';
      }).join('');
    }
    
    build();
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{css}</style>
      <div className="arg-card">
        <div className="arg-head">
          <h3>Today's activity</h3>
          <span className="arg-date">Mon, Jun 22</span>
        </div>
      
        <div className="arg-main">
          <svg className="arg-rings" viewBox="0 0 200 200" id="argRings"></svg>
      
          <div className="arg-legend" id="argLegend"></div>
        </div>
      </div>
    </>
  );
}
React + Tailwind
import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+

export default function ActivityRings() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var RINGS = [
      { name: 'Move',    value: 520, goal: 600, unit: 'cal', color: '#fb2c5a' },
      { name: 'Exercise',value: 38,  goal: 30,  unit: 'min', color: '#a3f307' },
      { name: 'Stand',   value: 9,   goal: 12,  unit: 'hr',  color: '#22d3ee' },
    ];
    var SIZE = 200, CENTER = 100, GAP = 8, STROKE = 17;
    var svg = document.getElementById('argRings');
    var NS = 'http://www.w3.org/2000/svg';
    
    function build() {
      RINGS.forEach(function (ring, i) {
        var r = CENTER - STROKE / 2 - 4 - i * (STROKE + GAP);
        var circ = 2 * Math.PI * r;
        var pct = Math.min(1, ring.value / ring.goal);
    
        var track = document.createElementNS(NS, 'circle');
        track.setAttribute('class', 'arg-track');
        track.setAttribute('cx', CENTER); track.setAttribute('cy', CENTER); track.setAttribute('r', r);
        track.setAttribute('stroke', ring.color); track.setAttribute('stroke-width', STROKE);
        track.setAttribute('opacity', '0.18');
        svg.appendChild(track);
    
        var prog = document.createElementNS(NS, 'circle');
        prog.setAttribute('class', 'arg-prog');
        prog.setAttribute('cx', CENTER); prog.setAttribute('cy', CENTER); prog.setAttribute('r', r);
        prog.setAttribute('stroke', ring.color); prog.setAttribute('stroke-width', STROKE);
        prog.setAttribute('transform', 'rotate(-90 ' + CENTER + ' ' + CENTER + ')');
        prog.setAttribute('stroke-dasharray', circ);
        prog.setAttribute('stroke-dashoffset', circ);   // start empty, animate to filled
        svg.appendChild(prog);
    
        // Animate after a frame so the transition runs.
        requestAnimationFrame(function () {
          requestAnimationFrame(function () { prog.setAttribute('stroke-dashoffset', circ * (1 - pct)); });
        });
      });
    
      document.getElementById('argLegend').innerHTML = RINGS.map(function (ring) {
        var pct = Math.round((ring.value / ring.goal) * 100);
        return '<div class="arg-row">' +
          '<div class="arg-row-top"><span class="arg-dot" style="background:' + ring.color + '"></span><span class="arg-name">' + ring.name + '</span></div>' +
          '<span class="arg-vals">' + ring.value + '<small>/' + ring.goal + ' ' + ring.unit + '</small></span>' +
          '<span class="arg-pct" style="color:' + ring.color + '">' + pct + '%' + (pct >= 100 ? ' ✓' : '') + '</span>' +
        '</div>';
      }).join('');
    }
    
    build();
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{`
* {
  box-sizing:border-box;margin:0;padding:0
}

body {
  font-family:system-ui,-apple-system,sans-serif;background:#0b1120;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px
}

.arg-head h3 {
  font-size:16px;font-weight:800;color:#f8fafc
}

.arg-track {
  fill:none;stroke-linecap:round
}

.arg-prog {
  fill:none;stroke-linecap:round;transition:stroke-dashoffset 1s cubic-bezier(.22,1,.36,1)
}

.arg-row {
  display:flex;flex-direction:column;gap:2px
}

.arg-row-top {
  display:flex;align-items:center;gap:7px
}

.arg-dot {
  width:9px;height:9px;border-radius:50%
}

.arg-name {
  font-size:12px;font-weight:700;color:#e5e7eb
}

.arg-vals {
  font-size:16px;font-weight:800;color:#f8fafc;font-variant-numeric:tabular-nums;padding-left:16px
}

.arg-vals small {
  font-size:11px;font-weight:600;color:#6b7280
}

.arg-pct {
  font-size:10.5px;font-weight:700;padding-left:16px
}
      `}</style>
      <div className="bg-[#111827] border border-[#1f2937] rounded-[20px] p-6 w-full max-w-[380px] shadow-[0_18px_44px_rgba(0,0,0,.45)]">
        <div className="arg-head flex items-baseline justify-between mb-1.5">
          <h3>Today's activity</h3>
          <span className="text-xs text-[#6b7280] font-semibold">Mon, Jun 22</span>
        </div>
      
        <div className="flex items-center gap-[22px] mt-3.5">
          <svg className="w-40 h-40 shrink-0" viewBox="0 0 200 200" id="argRings"></svg>
      
          <div className="flex flex-col gap-[13px]" id="argLegend"></div>
        </div>
      </div>
    </>
  );
}
Vue
<template>
  <div class="arg-card">
    <div class="arg-head">
      <h3>Today's activity</h3>
      <span class="arg-date">Mon, Jun 22</span>
    </div>
  
    <div class="arg-main">
      <svg class="arg-rings" viewBox="0 0 200 200" id="argRings"></svg>
  
      <div class="arg-legend" id="argLegend"></div>
    </div>
  </div>
</template>

<script setup>
import { onMounted } from 'vue';

var RINGS = [
  { name: 'Move',    value: 520, goal: 600, unit: 'cal', color: '#fb2c5a' },
  { name: 'Exercise',value: 38,  goal: 30,  unit: 'min', color: '#a3f307' },
  { name: 'Stand',   value: 9,   goal: 12,  unit: 'hr',  color: '#22d3ee' },
];
var SIZE = 200, CENTER = 100, GAP = 8, STROKE = 17;
let svg;
let NS;
function build() {
  RINGS.forEach(function (ring, i) {
    var r = CENTER - STROKE / 2 - 4 - i * (STROKE + GAP);
    var circ = 2 * Math.PI * r;
    var pct = Math.min(1, ring.value / ring.goal);

    var track = document.createElementNS(NS, 'circle');
    track.setAttribute('class', 'arg-track');
    track.setAttribute('cx', CENTER); track.setAttribute('cy', CENTER); track.setAttribute('r', r);
    track.setAttribute('stroke', ring.color); track.setAttribute('stroke-width', STROKE);
    track.setAttribute('opacity', '0.18');
    svg.appendChild(track);

    var prog = document.createElementNS(NS, 'circle');
    prog.setAttribute('class', 'arg-prog');
    prog.setAttribute('cx', CENTER); prog.setAttribute('cy', CENTER); prog.setAttribute('r', r);
    prog.setAttribute('stroke', ring.color); prog.setAttribute('stroke-width', STROKE);
    prog.setAttribute('transform', 'rotate(-90 ' + CENTER + ' ' + CENTER + ')');
    prog.setAttribute('stroke-dasharray', circ);
    prog.setAttribute('stroke-dashoffset', circ);   // start empty, animate to filled
    svg.appendChild(prog);

    // Animate after a frame so the transition runs.
    requestAnimationFrame(function () {
      requestAnimationFrame(function () { prog.setAttribute('stroke-dashoffset', circ * (1 - pct)); });
    });
  });

  document.getElementById('argLegend').innerHTML = RINGS.map(function (ring) {
    var pct = Math.round((ring.value / ring.goal) * 100);
    return '<div class="arg-row">' +
      '<div class="arg-row-top"><span class="arg-dot" style="background:' + ring.color + '"></span><span class="arg-name">' + ring.name + '</span></div>' +
      '<span class="arg-vals">' + ring.value + '<small>/' + ring.goal + ' ' + ring.unit + '</small></span>' +
      '<span class="arg-pct" style="color:' + ring.color + '">' + pct + '%' + (pct >= 100 ? ' ✓' : '') + '</span>' +
    '</div>';
  }).join('');
}

onMounted(() => {
  svg = document.getElementById('argRings');
  NS = 'http://www.w3.org/2000/svg';
  build();
});
</script>

<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0b1120;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}

.arg-card{background:#111827;border:1px solid #1f2937;border-radius:20px;padding:24px;width:100%;max-width:380px;box-shadow:0 18px 44px rgba(0,0,0,.45)}
.arg-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px}
.arg-head h3{font-size:16px;font-weight:800;color:#f8fafc}
.arg-date{font-size:12px;color:#6b7280;font-weight:600}

.arg-main{display:flex;align-items:center;gap:22px;margin-top:14px}
.arg-rings{width:160px;height:160px;flex-shrink:0}
.arg-track{fill:none;stroke-linecap:round}
.arg-prog{fill:none;stroke-linecap:round;transition:stroke-dashoffset 1s cubic-bezier(.22,1,.36,1)}

.arg-legend{display:flex;flex-direction:column;gap:13px}
.arg-row{display:flex;flex-direction:column;gap:2px}
.arg-row-top{display:flex;align-items:center;gap:7px}
.arg-dot{width:9px;height:9px;border-radius:50%}
.arg-name{font-size:12px;font-weight:700;color:#e5e7eb}
.arg-vals{font-size:16px;font-weight:800;color:#f8fafc;font-variant-numeric:tabular-nums;padding-left:16px}
.arg-vals small{font-size:11px;font-weight:600;color:#6b7280}
.arg-pct{font-size:10.5px;font-weight:700;padding-left:16px}
</style>
Angular
// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-activity-rings',
  standalone: true,
  imports: [CommonModule],
  encapsulation: ViewEncapsulation.None,
  template: `
    <div class="arg-card">
      <div class="arg-head">
        <h3>Today's activity</h3>
        <span class="arg-date">Mon, Jun 22</span>
      </div>
    
      <div class="arg-main">
        <svg class="arg-rings" viewBox="0 0 200 200" id="argRings"></svg>
    
        <div class="arg-legend" id="argLegend"></div>
      </div>
    </div>
  `,
  styles: [`
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#0b1120;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
    
    .arg-card{background:#111827;border:1px solid #1f2937;border-radius:20px;padding:24px;width:100%;max-width:380px;box-shadow:0 18px 44px rgba(0,0,0,.45)}
    .arg-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px}
    .arg-head h3{font-size:16px;font-weight:800;color:#f8fafc}
    .arg-date{font-size:12px;color:#6b7280;font-weight:600}
    
    .arg-main{display:flex;align-items:center;gap:22px;margin-top:14px}
    .arg-rings{width:160px;height:160px;flex-shrink:0}
    .arg-track{fill:none;stroke-linecap:round}
    .arg-prog{fill:none;stroke-linecap:round;transition:stroke-dashoffset 1s cubic-bezier(.22,1,.36,1)}
    
    .arg-legend{display:flex;flex-direction:column;gap:13px}
    .arg-row{display:flex;flex-direction:column;gap:2px}
    .arg-row-top{display:flex;align-items:center;gap:7px}
    .arg-dot{width:9px;height:9px;border-radius:50%}
    .arg-name{font-size:12px;font-weight:700;color:#e5e7eb}
    .arg-vals{font-size:16px;font-weight:800;color:#f8fafc;font-variant-numeric:tabular-nums;padding-left:16px}
    .arg-vals small{font-size:11px;font-weight:600;color:#6b7280}
    .arg-pct{font-size:10.5px;font-weight:700;padding-left:16px}
  `]
})
export class ActivityRingsComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    var RINGS = [
      { name: 'Move',    value: 520, goal: 600, unit: 'cal', color: '#fb2c5a' },
      { name: 'Exercise',value: 38,  goal: 30,  unit: 'min', color: '#a3f307' },
      { name: 'Stand',   value: 9,   goal: 12,  unit: 'hr',  color: '#22d3ee' },
    ];
    var SIZE = 200, CENTER = 100, GAP = 8, STROKE = 17;
    var svg = document.getElementById('argRings');
    var NS = 'http://www.w3.org/2000/svg';
    
    function build() {
      RINGS.forEach(function (ring, i) {
        var r = CENTER - STROKE / 2 - 4 - i * (STROKE + GAP);
        var circ = 2 * Math.PI * r;
        var pct = Math.min(1, ring.value / ring.goal);
    
        var track = document.createElementNS(NS, 'circle');
        track.setAttribute('class', 'arg-track');
        track.setAttribute('cx', CENTER); track.setAttribute('cy', CENTER); track.setAttribute('r', r);
        track.setAttribute('stroke', ring.color); track.setAttribute('stroke-width', STROKE);
        track.setAttribute('opacity', '0.18');
        svg.appendChild(track);
    
        var prog = document.createElementNS(NS, 'circle');
        prog.setAttribute('class', 'arg-prog');
        prog.setAttribute('cx', CENTER); prog.setAttribute('cy', CENTER); prog.setAttribute('r', r);
        prog.setAttribute('stroke', ring.color); prog.setAttribute('stroke-width', STROKE);
        prog.setAttribute('transform', 'rotate(-90 ' + CENTER + ' ' + CENTER + ')');
        prog.setAttribute('stroke-dasharray', circ);
        prog.setAttribute('stroke-dashoffset', circ);   // start empty, animate to filled
        svg.appendChild(prog);
    
        // Animate after a frame so the transition runs.
        requestAnimationFrame(function () {
          requestAnimationFrame(function () { prog.setAttribute('stroke-dashoffset', circ * (1 - pct)); });
        });
      });
    
      document.getElementById('argLegend').innerHTML = RINGS.map(function (ring) {
        var pct = Math.round((ring.value / ring.goal) * 100);
        return '<div class="arg-row">' +
          '<div class="arg-row-top"><span class="arg-dot" style="background:' + ring.color + '"></span><span class="arg-name">' + ring.name + '</span></div>' +
          '<span class="arg-vals">' + ring.value + '<small>/' + ring.goal + ' ' + ring.unit + '</small></span>' +
          '<span class="arg-pct" style="color:' + ring.color + '">' + pct + '%' + (pct >= 100 ? ' ✓' : '') + '</span>' +
        '</div>';
      }).join('');
    }
    
    build();

    // Bind inline-handler functions to the component so the template can call them
    Object.assign(this, { build });
  }
}