{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-ring",
  "type": "registry:block",
  "title": "Progress Rings",
  "description": "Activity-style progress rings that fill on real springs with a conic gradient to a bright head; past 100% the ring laps itself and the head casts a shadow over the tail. The centre readout counts and crossfades to the ring you hover or focus, and a click or the + nudges a value up.",
  "docs": "Progress Rings was added to public/vividsites/progress-ring.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/progress-ring",
  "categories": [
    "ui",
    "data-display"
  ],
  "files": [
    {
      "path": "components/vividsites/progress-ring.html",
      "type": "registry:file",
      "target": "public/vividsites/progress-ring.html",
      "content": "<!--\n  vs-progress-ring: activity-style progress rings drawn on canvas. Each ring fills on its own spring\n  (tension / friction, so it overshoots and settles), its stroke is a conic gradient from a deep start\n  to a bright head, and past 100% it keeps going round: the second lap runs over the first and the\n  round head casts a soft shadow onto the ring ahead of it, like the watch rings. The centre readout\n  counts with the spring and crossfades to whichever ring you hover or focus.\n  Click or tap a ring (or its \"+\" in the legend) to nudge it up a step; arrow keys adjust the focused ring.\n\n  Markup (rings from the outside in; one <data> = a single ring):\n    <div class=\"vs-progress-ring\" data-vs-progress-ring-legend aria-label=\"Today\">\n      <data value=\"412\" data-max=\"500\" data-label=\"Focus\" data-unit=\"min\" data-step=\"25\"></data>\n      <data value=\"3.4\" data-max=\"4\" data-label=\"Deep work\" data-unit=\"h\" data-step=\".5\" data-decimals=\"1\"></data>\n      <data value=\"118\" data-max=\"100\" data-label=\"Recovery\" data-unit=\"%\" data-step=\"10\"></data>\n    </div>\n  <data> attributes: value, data-max (100), data-label, data-unit (\"%\" shows as a percent sign),\n    data-step (a tenth of max), data-decimals (0), data-ring (palette slot 1-3; defaults to its position)\n  Root attributes:\n    data-vs-progress-ring-legend   show a legend beside the dial (label, value / max, a \"+\" button)\n    data-vs-progress-ring-theme    \"dark\" | \"light\" (default: follows the page's text colour)\n  Variables:\n    --vs-progress-ring-size        dial diameter                                  (320px)\n    --vs-progress-ring-thickness   stroke width as a fraction of the diameter    (.075)\n    --vs-progress-ring-tension     spring tension                                 (170)\n    --vs-progress-ring-friction    spring friction, lower = more bounce          (15)\n    --vs-progress-ring-1 / -2 / -3 ring colours: \"start head\" (two colours) or one colour\n    --vs-progress-ring-font        (inherit)   --vs-progress-ring-ink / -muted   text colours\n  Colours and spring variables are read live (on a style change), so they can be animated by theme code.\n  JS: el.vsProgressRing.set(index, value) .get(index) .reset() (springs back to the markup values) .replay()\n  Events: \"change\" bubbles from the root with detail { index, value, label }.\n  Accessibility: every ring is a focusable slider (aria-valuetext \"412 of 500 min\"); the canvas is decorative.\n  prefers-reduced-motion: values change in place, no springs.\n-->\n<style>\n.vs-progress-ring {\n  --vs-progress-ring-size: 320px;\n  --vs-progress-ring-thickness: .075;\n  --vs-progress-ring-tension: 170;\n  --vs-progress-ring-friction: 15;\n  --vs-progress-ring-1: #e8213f #ff6a4d;\n  --vs-progress-ring-2: #62c21a #c8f55c;\n  --vs-progress-ring-3: #1590d8 #52e2f8;\n  --vs-progress-ring-font: inherit;\n  --vs-progress-ring-ink: currentColor;          /* adopts the page's text colour unless a theme is set */\n  --vs-progress-ring-muted: color-mix(in srgb, currentColor 52%, transparent);\n  --vs-progress-ring-line: color-mix(in srgb, currentColor 9%, transparent);\n\n  display: inline-flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 28px 44px;\n  font-family: var(--vs-progress-ring-font); color: var(--vs-progress-ring-ink);\n  -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent;\n  font-variant-numeric: tabular-nums; max-width: 100%;\n}\n.vs-progress-ring *, .vs-progress-ring *::before, .vs-progress-ring *::after { box-sizing: border-box; }\n.vs-progress-ring[data-vs-progress-ring-theme=\"dark\"] {\n  --vs-progress-ring-ink: #eef2ee;\n  --vs-progress-ring-muted: rgba(238,242,238,.5);\n  --vs-progress-ring-line: rgba(238,242,238,.09);\n}\n.vs-progress-ring[data-vs-progress-ring-theme=\"light\"] {\n  --vs-progress-ring-ink: #141715;\n  --vs-progress-ring-muted: rgba(20,23,21,.52);\n  --vs-progress-ring-line: rgba(20,23,21,.1);\n}\n.vs-progress-ring > data { display: none; }\n.vs-progress-ring-dial {\n  position: relative; width: var(--vs-progress-ring-size); max-width: 100%; aspect-ratio: 1;\n  container-type: inline-size; cursor: pointer; touch-action: manipulation; user-select: none; -webkit-user-select: none;\n}\n.vs-progress-ring-dial canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }\n\n/* centre readout: two faces that crossfade */\n.vs-progress-ring-read { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; }\n.vs-progress-ring-face {\n  grid-area: 1 / 1; display: grid; justify-items: center; gap: .2cqw; text-align: center;\n  transition: opacity .32s cubic-bezier(.2,0,0,1), transform .5s cubic-bezier(.16,1,.3,1), filter .32s;\n}\n.vs-progress-ring-face.is-out { opacity: 0; transform: translateY(2.2cqw) scale(.96); filter: blur(4px); }\n.vs-progress-ring-face.is-out.is-up { transform: translateY(-2.2cqw) scale(.96); }\n.vs-progress-ring-lab { font-weight: 600; font-size: max(9px, 3.4cqw); letter-spacing: .16em; text-transform: uppercase; line-height: 1.2; color: var(--c, var(--vs-progress-ring-muted)); }\n.vs-progress-ring-num { font-weight: 600; font-size: 15.5cqw; line-height: .95; letter-spacing: -.02em; }\n.vs-progress-ring-num small { font-size: .42em; font-weight: 400; letter-spacing: 0; margin-left: .06em; color: var(--vs-progress-ring-muted); }\n.vs-progress-ring-sub { font-weight: 400; font-size: max(9px, 3.6cqw); letter-spacing: .1em; text-transform: uppercase; color: var(--vs-progress-ring-muted); }\n/* a single ring: percent large, label under it */\n.vs-progress-ring.is-single .vs-progress-ring-num { font-size: 22cqw; }\n.vs-progress-ring.is-single .vs-progress-ring-lab { font-size: max(9px, 6.4cqw); letter-spacing: .12em; order: 3; margin-top: .6cqw; }\n.vs-progress-ring.is-single .vs-progress-ring-sub { display: none; }\n\n/* \"+25\" that floats off the head when a ring is nudged */\n.vs-progress-ring-pop {\n  position: absolute; left: 0; top: 0; pointer-events: none; font-weight: 600; font-size: max(12px, 4.6cqw);\n  color: var(--c); white-space: nowrap; opacity: 0;\n  text-shadow: 0 1px 10px rgba(0,0,0,.45);\n}\n\n/* focusable sliders: invisible, the canvas draws their focus ring */\n.vs-progress-ring-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }\n\n/* legend */\n.vs-progress-ring-legend { display: grid; gap: 6px; min-width: 250px; margin: 0; padding: 0; list-style: none; }\n.vs-progress-ring-li {\n  position: relative; display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 14px;\n  padding: 12px 12px 12px 12px; border-radius: 16px; cursor: pointer;\n  transition: background .25s cubic-bezier(.2,0,0,1), opacity .25s;\n}\n.vs-progress-ring-li:hover, .vs-progress-ring-li.is-on { background: var(--vs-progress-ring-line); }\n.vs-progress-ring.is-hover .vs-progress-ring-li:not(.is-on) { opacity: .5; }\n.vs-progress-ring-li canvas { width: 34px; height: 34px; display: block; }\n.vs-progress-ring-li b { display: block; font-weight: 600; font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--c); }\n.vs-progress-ring-li span { display: block; margin-top: 3px; font-size: 22px; font-weight: 600; letter-spacing: -.01em; line-height: 1.1; }\n.vs-progress-ring-li span small { font-size: 13px; font-weight: 400; color: var(--vs-progress-ring-muted); letter-spacing: .02em; }\n.vs-progress-ring-plus {\n  all: unset; box-sizing: border-box; width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; cursor: pointer;\n  color: var(--vs-progress-ring-ink); background: var(--vs-progress-ring-line);\n  box-shadow: inset 0 0 0 .5px var(--vs-progress-ring-line), inset 0 1px 0 rgba(255,255,255,.06);\n  transition: transform .35s cubic-bezier(.34,1.56,.64,1), background .2s, color .2s;\n}\n.vs-progress-ring-plus svg { width: 14px; height: 14px; }\n.vs-progress-ring-plus:hover { transform: scale(1.08); background: var(--c); color: #0b0d0c; }\n.vs-progress-ring-plus:active { transform: scale(.88); transition-duration: .1s; }\n.vs-progress-ring-plus:focus-visible { outline: 2px solid var(--c); outline-offset: 2px; }\n.vs-progress-ring-plus:disabled { opacity: .35; cursor: not-allowed; transform: none; }\n</style>\n<div class=\"vs-progress-ring\" data-vs-progress-ring-legend aria-label=\"Today\">\n  <data value=\"412\" data-max=\"500\" data-label=\"Focus\" data-unit=\"min\" data-step=\"25\"></data>\n  <data value=\"3.4\" data-max=\"4\" data-label=\"Deep work\" data-unit=\"h\" data-step=\".5\" data-decimals=\"1\"></data>\n  <data value=\"118\" data-max=\"100\" data-label=\"Recovery\" data-unit=\"%\" data-step=\"10\"></data>\n</div>\n<script>\n(() => {\n  if (window.VsProgressRing) return;\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const TAU = Math.PI * 2, TOP = -Math.PI / 2;\n  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n  const probe = document.createElement('canvas').getContext('2d');\n  const rgb = (c) => {\n    probe.fillStyle = '#000'; probe.fillStyle = c; const s = probe.fillStyle;\n    if (s[0] === '#') return [parseInt(s.slice(1, 3), 16), parseInt(s.slice(3, 5), 16), parseInt(s.slice(5, 7), 16)];\n    const m = s.match(/[\\d.]+/g) || [0, 0, 0]; return [+m[0], +m[1], +m[2]];\n  };\n  const mix = (a, b, k) => a.map((v, i) => Math.round(v + (b[i] - v) * k));\n  const css = (c, a = 1) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;\n  const plusSvg = '<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"><path d=\"M8 3v10M3 8h10\"/></svg>';\n\n  // one ring onto a 2d context (user units); p = progress in laps\n  function ring(ctx, cx, cy, r, w, p, cs, ch, o) {\n    const { alpha = 1, glow = 0, track = .15, dpr = 1, focus = null, shade = 1 } = o;\n    ctx.save(); ctx.globalAlpha = alpha; ctx.lineWidth = w; ctx.lineCap = 'butt';\n    ctx.strokeStyle = css(ch, track); ctx.beginPath(); ctx.arc(cx, cy, r, 0, TAU); ctx.stroke();\n    if (focus) { ctx.lineWidth = 1.5; ctx.strokeStyle = focus; for (const rr of [r + w / 2 + 3.5, r - w / 2 - 3.5]) { ctx.beginPath(); ctx.arc(cx, cy, rr, 0, TAU); ctx.stroke(); } ctx.lineWidth = w; }\n    if (p <= .0015) { ctx.restore(); return; }\n    const head = TOP + p * TAU, hx = cx + Math.cos(head) * r, hy = cy + Math.sin(head) * r;\n    const lap = p > 1;\n    const g = ctx.createConicGradient(lap ? head : TOP, cx, cy);\n    g.addColorStop(0, css(cs)); g.addColorStop(lap ? 1 : Math.max(.001, p), css(ch)); if (!lap) g.addColorStop(1, css(ch));\n    ctx.strokeStyle = g;\n    if (glow > .01) { ctx.shadowColor = css(ch, .34 * glow); ctx.shadowBlur = w * .36 * glow * dpr; }\n    ctx.beginPath(); lap ? ctx.arc(cx, cy, r, 0, TAU) : ctx.arc(cx, cy, r, TOP, head); ctx.stroke();\n    ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;\n    if (!lap) { ctx.fillStyle = css(cs); ctx.beginPath(); ctx.arc(cx + Math.cos(TOP) * r, cy + Math.sin(TOP) * r, w / 2, 0, TAU); ctx.fill(); }\n    // the head's cast shadow, forward along the ring, clipped to the band (drawn off-canvas, shadow only)\n    const tx = -Math.sin(head), ty = Math.cos(head), k = (lap ? 1 : clamp(p * 1.3, 0, .55)) * shade;\n    ctx.save();\n    ctx.beginPath(); ctx.arc(cx, cy, r + w / 2 + .5, 0, TAU); ctx.arc(cx, cy, Math.max(0, r - w / 2 - .5), 0, TAU, true); ctx.clip();\n    ctx.shadowColor = `rgba(0,0,0,${.62 * k})`; ctx.shadowBlur = w * .42 * dpr;\n    ctx.shadowOffsetX = (4000 + tx * w * .26) * dpr; ctx.shadowOffsetY = ty * w * .26 * dpr;\n    ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(hx - 4000, hy, w / 2, 0, TAU); ctx.fill();\n    ctx.restore();\n    // re-lay the tail just behind the head over any shadow that fell backwards, then the cap\n    const back = Math.min(p * TAU, (w / r) * 1.5);\n    ctx.beginPath(); ctx.arc(cx, cy, r, head - back, head); ctx.stroke();\n    ctx.fillStyle = css(ch); ctx.beginPath(); ctx.arc(hx, hy, w / 2, 0, TAU); ctx.fill();\n    ctx.restore();\n  }\n\n  function mount(root) {\n    if (root.vsProgressRing) return root.vsProgressRing;\n    const items = [...root.querySelectorAll(':scope > data')].map((d, i) => {\n      const max = parseFloat(d.dataset.max || '100') || 100;\n      const v0 = parseFloat(d.getAttribute('value')) || 0;\n      return {\n        d, i, max, v0, value: v0, label: d.dataset.label || `Ring ${i + 1}`, unit: d.dataset.unit || '',\n        step: parseFloat(d.dataset.step || '') || max / 10, dec: parseInt(d.dataset.decimals || '0', 10),\n        slot: parseInt(d.dataset.ring || String(i + 1), 10), p: 0, v: 0, hl: 0, dim: 0, due: 0, cs: [0, 0, 0], ch: [0, 0, 0]\n      };\n    });\n    if (!items.length) return;\n    const single = items.length === 1; root.classList.toggle('is-single', single);\n    const el = (tag, cls, parent, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html) e.innerHTML = html; if (parent) parent.appendChild(e); return e; };\n    const dial = el('div', 'vs-progress-ring-dial', root);\n    const cv = el('canvas', null, dial); cv.setAttribute('aria-hidden', 'true');\n    const ctx = cv.getContext('2d');\n    // each ring is drawn to a layer first, so dimming fades it as one piece (no overlap seams)\n    const off = document.createElement('canvas'), octx = off.getContext('2d');\n    const read = el('div', 'vs-progress-ring-read', dial); read.setAttribute('aria-hidden', 'true');\n    const faceHtml = '<span class=\"vs-progress-ring-lab\"></span><span class=\"vs-progress-ring-num\"></span><span class=\"vs-progress-ring-sub\"></span>';\n    const faces = [el('div', 'vs-progress-ring-face', read, faceHtml), el('div', 'vs-progress-ring-face is-out', read, faceHtml)];\n    let face = 0, shown = -1;\n    const fmt = (it, v) => v.toLocaleString(undefined, { minimumFractionDigits: it.dec, maximumFractionDigits: it.dec });\n    const pct = (it) => it.unit === '%';\n    const numHtml = (it, v) => pct(it) ? `${fmt(it, v)}<small>%</small>` : fmt(it, v);\n\n    // sliders (focus + keyboard), legend\n    const sr = el('div', 'vs-progress-ring-sr', dial);\n    for (const it of items) {\n      const s = el('div', null, sr); s.tabIndex = 0; s.setAttribute('role', 'slider');\n      s.setAttribute('aria-label', it.label); s.setAttribute('aria-valuemin', '0'); s.setAttribute('aria-valuemax', String(it.max * 3));\n      it.slider = s;\n      s.addEventListener('focus', () => { it.focus = true; setActive(); kick(); });\n      s.addEventListener('blur', () => { it.focus = false; setActive(); kick(); });\n      s.addEventListener('keydown', (e) => {\n        const d = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 5, PageDown: -5 }[e.key];\n        if (d) { e.preventDefault(); set(it.i, it.value + d * it.step, d > 0); }\n        else if (e.key === 'Home') { e.preventDefault(); set(it.i, 0); }\n        else if (e.key === 'End') { e.preventDefault(); set(it.i, it.max); }\n      });\n    }\n    let legend = null;\n    if (root.hasAttribute('data-vs-progress-ring-legend')) {\n      legend = el('div', 'vs-progress-ring-legend', root);\n      for (const it of items) {\n        const li = el('div', 'vs-progress-ring-li', legend,\n          `<canvas aria-hidden=\"true\"></canvas><div><b>${it.label}</b><span aria-hidden=\"true\"></span></div><button type=\"button\" class=\"vs-progress-ring-plus\" aria-label=\"Add ${it.step} ${it.unit} to ${it.label}\">${plusSvg}</button>`);\n        it.li = li; it.liNum = li.querySelector('span'); it.liCv = li.querySelector('canvas'); it.liCtx = it.liCv.getContext('2d');\n        li.addEventListener('pointerenter', (e) => { if (e.pointerType !== 'touch') { hover = it.i; setActive(); kick(); } });\n        li.addEventListener('pointerleave', () => { if (hover === it.i) { hover = -1; setActive(); kick(); } });\n        li.querySelector('button').addEventListener('click', () => set(it.i, it.value + it.step, true));\n      }\n    }\n\n    // ── config (read on style/attribute changes) ──\n    let inkRGB = [238, 242, 238], S = 0, dpr = 1, thick = .075, K = 170, C = 15, light = false, track = .15;\n    const readCfg = () => {\n      const cs = getComputedStyle(root);\n      const num = (n, fb) => { const v = parseFloat(cs.getPropertyValue(n)); return isNaN(v) ? fb : v; };\n      thick = clamp(num('--vs-progress-ring-thickness', .075), .02, single ? .3 : .16);\n      K = Math.max(10, num('--vs-progress-ring-tension', 170)); C = Math.max(1, num('--vs-progress-ring-friction', 15));\n      const th = root.dataset.vsProgressRingTheme, ink = rgb(cs.color);\n      light = th === 'light' || (th !== 'dark' && (ink[0] * .2126 + ink[1] * .7152 + ink[2] * .0722) < 128);\n      track = light ? .14 : .13; inkRGB = ink;\n      for (const it of items) {\n        const raw = (cs.getPropertyValue(`--vs-progress-ring-${it.slot}`) || '#ff6a4d').trim();\n        const parts = raw.match(/#[0-9a-f]{3,8}|rgba?\\([^)]*\\)|hsla?\\([^)]*\\)|[a-z]+/gi) || ['#ff6a4d'];\n        it.ch = rgb(parts[parts.length - 1]);\n        it.cs = parts.length > 1 ? rgb(parts[0]) : mix(it.ch, [0, 0, 0], .42);\n        // text in the ring's colour: its bright head on dark, its deep start on light\n        const hex = css(light ? mix(it.cs, [0, 0, 0], .08) : it.ch);\n        if (it.li) it.li.style.setProperty('--c', hex);\n        it.css = hex;\n      }\n      shown = -1; setActive(); kick();\n    };\n    const size = () => {\n      S = dial.clientWidth; dpr = Math.min(2, devicePixelRatio || 1);\n      cv.width = off.width = Math.round(S * dpr); cv.height = off.height = Math.round(S * dpr);\n      for (const it of items) if (it.liCv) { it.liCv.width = 34 * dpr; it.liCv.height = 34 * dpr; }\n      kick();\n    };\n    const geom = () => {\n      const w = S * thick, gap = Math.max(2, w * .18), pad = w * .35 + 4;\n      return items.map((it, i) => ({ w, r: S / 2 - pad - w / 2 - i * (w + gap) }));\n    };\n\n    // ── state ──\n    let hover = -1, active = 0, raf = 0, last = 0, visible = false, started = false;\n    const setActive = () => {\n      const f = items.find((x) => x.focus && x.slider.matches(':focus-visible'));\n      const a = hover >= 0 ? hover : f ? f.i : 0;\n      root.classList.toggle('is-hover', hover >= 0);\n      items.forEach((x) => x.li && x.li.classList.toggle('is-on', x.i === a && (hover >= 0 || !!f)));\n      if (a === active && shown !== -1) return;\n      const dir = a > active; active = a;\n      // crossfade: fill the hidden face, swap\n      const out = faces[face], inn = faces[1 - face]; face = 1 - face;\n      const it = items[a];\n      inn.querySelector('.vs-progress-ring-lab').textContent = it.label;\n      inn.querySelector('.vs-progress-ring-lab').style.setProperty('--c', it.css);\n      inn.querySelector('.vs-progress-ring-sub').textContent = pct(it) ? `of ${fmt(it, it.max)}%` : `of ${fmt(it, it.max)} ${it.unit}`;\n      if (shown === -1) { inn.style.transition = out.style.transition = 'none'; requestAnimationFrame(() => (inn.style.transition = out.style.transition = '')); }\n      inn.classList.toggle('is-up', !dir); out.classList.toggle('is-up', dir);\n      inn.classList.remove('is-out'); out.classList.add('is-out');\n      shown = a; it._txt = '';\n    };\n    const set = (i, v, pop, silent) => {\n      const it = items[i]; if (!it) return;\n      v = +clamp(v, 0, it.max * 3).toFixed(it.dec + 2);\n      if (pop && v > it.value && S) popAt(it, `+${fmt(it, v - it.value)}`, v);\n      it.value = v; it.due = 0;\n      it.slider.setAttribute('aria-valuenow', String(v));\n      it.slider.setAttribute('aria-valuetext', `${fmt(it, v)} of ${fmt(it, it.max)} ${pct(it) ? 'percent' : it.unit}`);\n      if (it.li) it.li.querySelector('button').disabled = v >= it.max * 3;\n      if (!silent) root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { index: i, value: v, label: it.label } }));\n      kick();\n    };\n    const popAt = (it, txt, to) => {\n      if (reduced) return;\n      const g = geom()[it.i], a = TOP + (to / it.max) * TAU;\n      // springs out of the ring head, radially, in the ring's bright hue, then fades\n      const p = el('span', 'vs-progress-ring-pop', dial); p.textContent = txt; p.style.setProperty('--c', light ? it.css : css(it.ch));\n      const hx = S / 2 + Math.cos(a) * g.r, hy = S / 2 + Math.sin(a) * g.r, d = g.w * .5 + 16;\n      p.style.left = hx / S * 100 + '%'; p.style.top = hy / S * 100 + '%';\n      const tx = Math.cos(a) * d, ty = Math.sin(a) * d - 10;\n      p.animate([\n        { opacity: 0, transform: 'translate(-50%,-50%) scale(.5)', easing: 'cubic-bezier(.34,1.56,.64,1)' },\n        { opacity: 1, transform: `translate(calc(-50% + ${tx * .9}px), calc(-50% + ${ty * .9}px)) scale(1.06)`, offset: .24, easing: 'cubic-bezier(.2,0,0,1)' },\n        { opacity: 1, transform: `translate(calc(-50% + ${tx}px), calc(-50% + ${ty}px)) scale(1)`, offset: .62, easing: 'cubic-bezier(.4,0,1,1)' },\n        { opacity: 0, transform: `translate(calc(-50% + ${tx * 1.15}px), calc(-50% + ${ty * 1.15 - 10}px)) scale(.96)` }\n      ], { duration: 1100, fill: 'forwards' }).onfinish = () => p.remove();\n    };\n\n    const frame = (now) => {\n      raf = 0;\n      const dt = Math.min(.05, (now - (last || now)) / 1000); last = now;\n      let moving = false;\n      for (const it of items) {\n        if (it.due > 0) { it.due -= dt; moving = true; continue; }\n        const goal = it.value / it.max;\n        if (reduced) { it.p = goal; it.v = 0; }\n        else for (let h = dt; h > 1e-6; h -= 1 / 240) { const s = Math.min(h, 1 / 240); it.v += (K * (goal - it.p) - C * it.v) * s; it.p += it.v * s; }\n        if (Math.abs(goal - it.p) > 1e-4 || Math.abs(it.v) > 1e-3) moving = true; else { it.p = goal; it.v = 0; }\n        if (it.p < 0) { it.p = 0; it.v = Math.abs(it.v) * .2; }\n        const on = hover === it.i || (it.focus && it.slider.matches(':focus-visible')) ? 1 : 0;\n        const dimGoal = hover >= 0 && hover !== it.i ? 1 : 0;\n        const e = reduced ? 1 : 1 - Math.exp(-dt * 12);\n        it.hl += (on - it.hl) * e; it.dim += (dimGoal - it.dim) * e;\n        if (Math.abs(on - it.hl) > .005 || Math.abs(dimGoal - it.dim) > .005) moving = true;\n      }\n      // draw\n      ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, cv.width, cv.height);\n      const G = geom();\n      // the lit ring is drawn last so its glow sits over its neighbours\n      const order = items.slice().sort((a, b) => a.hl - b.hl);\n      for (const it of order) {\n        const i = it.i, b = it.hl * .12, ch = mix(it.ch, [255, 255, 255], b), cs = mix(it.cs, it.ch, b * 1.4);\n        const focus = it.focus && it.slider.matches(':focus-visible') ? css(inkRGB, .85) : null;\n        octx.setTransform(1, 0, 0, 1, 0, 0); octx.clearRect(0, 0, off.width, off.height); octx.setTransform(dpr, 0, 0, dpr, 0, 0);\n        ring(octx, S / 2, S / 2, G[i].r, G[i].w, it.p, cs, ch, { glow: it.hl, track, dpr, focus, shade: light ? .45 : 1 });\n        ctx.globalAlpha = 1 - it.dim * .24; ctx.drawImage(off, 0, 0); ctx.globalAlpha = 1;\n      }\n      items.forEach((it) => {\n        const b = it.hl * .12, ch = mix(it.ch, [255, 255, 255], b), cs = mix(it.cs, it.ch, b * 1.4);\n        if (it.liCtx) {\n          const c = it.liCtx; c.setTransform(dpr, 0, 0, dpr, 0, 0); c.clearRect(0, 0, 34, 34);\n          ring(c, 17, 17, 12.5, 6, it.p, cs, ch, { track, dpr });\n          const txt = numHtml(it, it.p * it.max) + `<small> / ${fmt(it, it.max)}${pct(it) ? '%' : ' ' + it.unit}</small>`;\n          if (txt !== it._li) { it.liNum.innerHTML = txt; it._li = txt; }\n        }\n      });\n      // readout counts with the active ring's spring\n      const it = items[active], cur = faces[face].querySelector('.vs-progress-ring-num');\n      const txt = numHtml(it, Math.max(0, it.p * it.max));\n      if (txt !== it._txt) { cur.innerHTML = txt; it._txt = txt; }\n      if (moving && visible) raf = requestAnimationFrame(frame);\n    };\n    const kick = () => { if (!raf && visible && S) { last = 0; raf = requestAnimationFrame(frame); } };\n\n    // pointer: which band is under the pointer\n    const bandAt = (e) => {\n      const b = dial.getBoundingClientRect(), x = e.clientX - b.left - b.width / 2, y = e.clientY - b.top - b.height / 2;\n      const d = Math.hypot(x, y) * (S / b.width), G = geom();\n      if (single) return d <= G[0].r + G[0].w / 2 + 6 ? 0 : -1;\n      let best = -1, bd = 1e9;\n      G.forEach((g, i) => { const dd = Math.abs(d - g.r); if (dd < g.w / 2 + 4 && dd < bd) { bd = dd; best = i; } });\n      return best;\n    };\n    dial.addEventListener('pointermove', (e) => { if (e.pointerType === 'touch') return; const h = bandAt(e); dial.style.cursor = h >= 0 ? 'pointer' : 'default'; if (h !== hover) { hover = h; setActive(); kick(); } });\n    dial.addEventListener('pointerleave', () => { if (hover !== -1) { hover = -1; setActive(); kick(); } });\n    dial.addEventListener('click', (e) => {\n      const h = bandAt(e); if (h < 0) return;\n      if (e.pointerType === 'touch' || !matchMedia('(hover: hover)').matches) { hover = h; setActive(); clearTimeout(dial._t); dial._t = setTimeout(() => { hover = -1; setActive(); kick(); }, 1400); }\n      set(h, items[h].value + items[h].step, true);\n    });\n\n    new ResizeObserver(size).observe(dial);\n    new MutationObserver(readCfg).observe(root, { attributes: true, attributeFilter: ['style', 'data-vs-progress-ring-theme'] });\n    const io = new IntersectionObserver(([en]) => {\n      visible = en.isIntersecting;\n      if (visible && !started) { started = true; items.forEach((it, i) => (it.due = reduced ? 0 : .15 + i * .12)); }\n      if (visible) kick(); else if (raf) { cancelAnimationFrame(raf); raf = 0; }\n    }, { threshold: .15 });\n    io.observe(dial);\n    items.forEach((it) => set(it.i, it.value, false, true));\n    readCfg(); size();\n\n    return (root.vsProgressRing = {\n      set: (i, v) => set(i, v), get: (i) => items[i] && items[i].value,\n      reset: () => items.forEach((it) => set(it.i, it.v0)),\n      replay: () => items.forEach((it, i) => { it.p = 0; it.v = 0; it.due = reduced ? 0 : .1 + i * .12; kick(); })\n    });\n  }\n  window.VsProgressRing = { mount };\n  const init = () => document.querySelectorAll('.vs-progress-ring').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Data display",
    "tags": [
      "progress ring",
      "activity rings",
      "radial progress",
      "gauge",
      "meter",
      "kpi",
      "spring",
      "canvas",
      "dashboard"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;600&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#ff6a4d",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/progress-ring/index.html",
    "thumbnail": "https://vividsites.app/api/component/progress-ring/thumb.jpg",
    "video": "https://vividsites.app/api/component/progress-ring/thumb.mp4",
    "page": "https://vividsites.app/c/progress-ring"
  }
}