{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"progress-ring","type":"registry:component","title":"Circular Progress Ring","description":"A progress ring with a gradient stroke and a glowing head, tick marks that light up behind the arc, a centre value whose digits roll, and an indeterminate mode with a rotating arc. Springs to any value. No dependencies.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/progress-ring.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useRef } from \"react\";\n\nexport type ProgressRingProps = {\n  /** Value to fill to, 0-100. Changing it springs the ring to the new value. */\n  target?: number;\n  /** Outer size in px (the ring plus its tick marks). */\n  size?: number;\n  /** Stroke width of the ring in px. */\n  strokeWidth?: number;\n  /** Roughly how long the spring takes to settle, in seconds. */\n  duration?: number;\n  /** Shows a rotating arc instead of a value (loading with unknown length). */\n  indeterminate?: boolean;\n  /** Draws the tick marks around the ring (hidden automatically under 72px). */\n  ticks?: boolean;\n  /** Small caption under the value; also the accessible name. */\n  label?: string;\n  /** Gradient start colour (hex). Default is the classic red. */\n  from?: string;\n  /** Gradient end colour (hex). */\n  to?: string;\n  /** Goes on the outer element. Text colour (currentColor) paints the track, ticks and numerals. */\n  className?: string;\n};\n\nconst HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;\nconst SEGMENTS = 90;\nconst TAU = Math.PI * 2;\nconst nowMs = () => performance.now();\n\nfunction rgb(hex: string, fallback: [number, number, number]): [number, number, number] {\n  if (!HEX.test(hex)) return fallback;\n  const h = hex.slice(1);\n  const f = h.length === 3 ? h.split(\"\").map((c) => c + c).join(\"\") : h;\n  return [parseInt(f.slice(0, 2), 16), parseInt(f.slice(2, 4), 16), parseInt(f.slice(4, 6), 16)];\n}\n\nfunction mix(a: [number, number, number], b: [number, number, number], t: number) {\n  const k = Math.min(1, Math.max(0, t));\n  return (\n    \"rgb(\" +\n    Math.round(a[0] + (b[0] - a[0]) * k) +\n    \",\" +\n    Math.round(a[1] + (b[1] - a[1]) * k) +\n    \",\" +\n    Math.round(a[2] + (b[2] - a[2]) * k) +\n    \")\"\n  );\n}\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\nconst r3 = (n: number) => Math.round(n * 1000) / 1000;\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];\nconst FADE = \"linear-gradient(to bottom, transparent, #000 24%, #000 76%, transparent)\";\n\nfunction Column({ colRef, stripRef }: { colRef: (el: HTMLSpanElement | null) => void; stripRef: (el: HTMLSpanElement | null) => void }) {\n  return (\n    <span\n      ref={colRef}\n      className=\"inline-block overflow-hidden align-baseline\"\n      style={{ height: \"1.3em\", marginTop: \"-0.15em\", marginBottom: \"-0.15em\", width: \"0.62em\", WebkitMaskImage: FADE, maskImage: FADE }}\n    >\n      <span ref={stripRef} className=\"block will-change-transform\" style={{ transform: \"translateY(0%)\" }}>\n        {DIGITS.map((d, i) => (\n          <span key={i} className=\"block text-center\" style={{ height: \"1.3em\", lineHeight: \"1.3em\" }}>\n            {d}\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\n\nexport function ProgressRing({\n  target = 72,\n  size = 120,\n  strokeWidth = 10,\n  duration = 1.2,\n  indeterminate = false,\n  ticks = true,\n  label,\n  from = \"#ff2d2d\",\n  to = \"#ff8a3d\",\n  className,\n}: ProgressRingProps) {\n  const uid = useId().replace(/[^a-zA-Z0-9]/g, \"\");\n  const rootRef = useRef<HTMLDivElement>(null);\n  const maskCircle = useRef<SVGCircleElement>(null);\n  const headCore = useRef<SVGCircleElement>(null);\n  const leadRef = useRef<SVGPathElement>(null);\n  const headHalo = useRef<SVGCircleElement>(null);\n  const tickG = useRef<SVGGElement>(null);\n  const arcG = useRef<SVGGElement>(null);\n  const valueEl = useRef<HTMLSpanElement>(null);\n  const cols = useRef<(HTMLSpanElement | null)[]>([null, null, null]);\n  const strips = useRef<(HTMLSpanElement | null)[]>([null, null, null]);\n  const api = useRef<{ wake: () => void; point: (f: number | null) => void } | null>(null);\n  const live = useRef({ target, indeterminate, inView: false, reduced: false });\n\n  const showTicks = ticks && size >= 72;\n  const sw = clamp(strokeWidth, 2, size * 0.3);\n  const c = size / 2;\n  const tickLen = clamp(size * 0.05, 3, 8);\n  const bump = tickLen * 1.3;\n  const rb = showTicks ? c - 1.5 - bump - tickLen * 0.2 : c;\n  const R = showTicks ? rb - 4 - sw / 2 : c - sw / 2;\n  const C = TAU * R;\n  const fromRgb = rgb(from, [255, 45, 45]);\n  const toRgb = rgb(to, [255, 138, 61]);\n  const cfg = useRef({ c, R, C, rb, tickLen, bump, sw, fromRgb, toRgb, showTicks, duration });\n  const maskId = \"pr-m-\" + uid;\n  const blurId = \"pr-b-\" + uid;\n\n  useEffect(() => {\n    cfg.current = { c, R, C, rb, tickLen, bump, sw, fromRgb, toRgb, showTicks, duration };\n    live.current.target = clamp(target, 0, 100);\n    live.current.indeterminate = indeterminate;\n    api.current?.wake();\n  });\n\n  useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    live.current.reduced = mq.matches;\n    const onMq = () => {\n      live.current.reduced = mq.matches;\n      api.current?.wake();\n    };\n    mq.addEventListener(\"change\", onMq);\n    let io: IntersectionObserver | null = null;\n    if (typeof IntersectionObserver === \"undefined\") {\n      live.current.inView = true;\n    } else {\n      io = new IntersectionObserver(\n        (entries) => {\n          if (entries.some((e) => e.isIntersecting)) {\n            live.current.inView = true;\n            io?.disconnect();\n            api.current?.wake();\n          }\n        },\n        { threshold: 0.3 },\n      );\n      io.observe(el);\n    }\n\n    const s = { v: 0, vel: 0, ind: 0, h: 0, hT: 0, hF: 0, t0: nowMs(), last: nowMs(), raf: 0, tickState: [] as number[] };\n    const tickEls = () => (tickG.current ? Array.from(tickG.current.children) : []);\n\n    const draw = (ts: number) => {\n      const g = cfg.current;\n      const L = live.current;\n      const tt = (ts - s.t0) / 1000;\n      const vC = clamp(s.v, 0, 100);\n      let lenF = vC / 100;\n      let rot = 0;\n      if (s.ind > 0.001) {\n        const spin = L.reduced ? 0 : ((tt * 240) % 360);\n        const Lf = L.reduced ? 0.28 : 0.1 + 0.5 * (0.5 - 0.5 * Math.cos((TAU * tt) / 2.4));\n        lenF = lenF + (Lf - lenF) * s.ind;\n        rot = spin * s.ind;\n      }\n      const mc = maskCircle.current;\n      if (mc) {\n        mc.setAttribute(\"stroke-dasharray\", (lenF * g.C).toFixed(2) + \" \" + g.C.toFixed(2));\n        mc.setAttribute(\"transform\", \"rotate(\" + (rot - 90).toFixed(2) + \" \" + g.c + \" \" + g.c + \")\");\n        mc.setAttribute(\"opacity\", lenF * g.C > 0.05 ? \"1\" : \"0\");\n      }\n      const theta = rot + lenF * 360;\n      const rad = ((theta - 90) * Math.PI) / 180;\n      const hx = g.c + g.R * Math.cos(rad);\n      const hy = g.c + g.R * Math.sin(rad);\n      const frac = (((theta / 360) % 1) + 1) % 1;\n      const lead = leadRef.current;\n      if (lead) lead.setAttribute(\"opacity\", (1 - clamp((lenF - 0.93) / 0.06, 0, 1)).toFixed(3));\n      const done = clamp((vC - 98.5) / 1.5, 0, 1) * (1 - s.ind);\n      const headOp = lenF * g.C > 0.05 ? 1 - done : 0;\n      const core = headCore.current;\n      const halo = headHalo.current;\n      if (core && halo) {\n        core.setAttribute(\"cx\", hx.toFixed(2));\n        core.setAttribute(\"cy\", hy.toFixed(2));\n        core.setAttribute(\"opacity\", headOp.toFixed(3));\n        core.setAttribute(\"r\", (g.sw * (0.2 + 0.06 * s.h)).toFixed(2));\n        halo.setAttribute(\"cx\", hx.toFixed(2));\n        halo.setAttribute(\"cy\", hy.toFixed(2));\n        halo.setAttribute(\"r\", (g.sw * (0.85 + 0.45 * s.h)).toFixed(2));\n        halo.setAttribute(\"fill\", mix(g.fromRgb, g.toRgb, frac));\n        halo.setAttribute(\"opacity\", (headOp * (0.75 + 0.25 * s.h)).toFixed(3));\n      }\n      if (g.showTicks) {\n        const els = tickEls();\n        const n = els.length;\n        for (let i = 0; i < n; i++) {\n          const f = i / n;\n          const rel = (((f - rot / 360) % 1) + 1) % 1;\n          const lit = lenF > 0.0005 && rel <= lenF + 1e-6;\n          const trail = lenF > 0 ? rel / lenF : 0;\n          let op = lit ? 0.3 + 0.7 * Math.pow(trail, 2.2) : 0.16;\n          const dd = Math.abs(f - s.hF);\n          const d = Math.min(dd, 1 - dd);\n          const bp = s.h * Math.exp(-Math.pow(d / 0.045, 2));\n          op = Math.min(1, op + 0.55 * bp);\n          const major = i % (n / 4) === 0;\n          const len = g.tickLen * (major ? 1.45 : 1) + g.bump * bp;\n          const t = els[i] as SVGLineElement;\n          t.setAttribute(\"y1\", (g.c - g.rb - len).toFixed(2));\n          t.setAttribute(\"opacity\", op.toFixed(3));\n          const state = lit ? 1 : 0;\n          if (s.tickState[i] !== state) {\n            s.tickState[i] = state;\n            t.setAttribute(\"stroke\", lit ? mix(g.fromRgb, g.toRgb, f) : \"currentColor\");\n          }\n        }\n      }\n      const arc = arcG.current;\n      if (arc) arc.setAttribute(\"opacity\", \"1\");\n      // value digits\n      const ve = valueEl.current;\n      if (ve) ve.style.opacity = (1 - s.ind).toFixed(3);\n      const ones = vC % 10;\n      const tensPos = Math.floor(vC / 10) + clamp(ones - 9, 0, 1);\n      const pos = [vC >= 99 ? 1 : 0, tensPos, ones];\n      const vis = [clamp(vC - 99, 0, 1), clamp(vC - 9, 0, 1), 1];\n      for (let k = 0; k < 3; k++) {\n        const col = cols.current[k];\n        const strip = strips.current[k];\n        if (!col || !strip) continue;\n        col.style.width = (0.62 * vis[k]!).toFixed(3) + \"em\";\n        col.style.opacity = vis[k]!.toFixed(3);\n        // hundreds shows a 1; tens/ones roll through the strip\n        const p = k === 0 ? 1 : pos[k]!;\n        strip.style.transform = \"translateY(\" + (-(p * 100) / 11).toFixed(3) + \"%)\";\n      }\n    };\n\n    const frame = (ts: number) => {\n      s.raf = 0;\n      const L = live.current;\n      const g = cfg.current;\n      const dt = Math.min(0.05, Math.max(0.001, (ts - s.last) / 1000));\n      s.last = ts;\n      const T = L.inView ? L.target : 0;\n      let awake = false;\n      if (L.reduced) {\n        s.v = T;\n        s.vel = 0;\n        s.ind = L.indeterminate ? 1 : 0;\n      } else {\n        const w = 5 / Math.max(0.2, g.duration);\n        const z = 0.82;\n        let rem = dt;\n        while (rem > 0) {\n          const h = Math.min(rem, 1 / 120);\n          s.vel += (w * w * (T - s.v) - 2 * z * w * s.vel) * h;\n          s.v += s.vel * h;\n          rem -= h;\n        }\n        if (Math.abs(T - s.v) < 0.005 && Math.abs(s.vel) < 0.02) {\n          s.v = T;\n          s.vel = 0;\n        } else awake = true;\n        const it = L.indeterminate ? 1 : 0;\n        s.ind += (it - s.ind) * (1 - Math.exp(-dt * 7));\n        if (Math.abs(it - s.ind) < 0.002) s.ind = it;\n        else awake = true;\n        if (L.indeterminate) awake = true;\n      }\n      s.h += (s.hT - s.h) * (1 - Math.exp(-dt * 9));\n      if (Math.abs(s.hT - s.h) < 0.002) s.h = s.hT;\n      else awake = true;\n      draw(ts);\n      if (awake) s.raf = requestAnimationFrame(frame);\n    };\n\n    const wake = () => {\n      if (s.raf) return;\n      s.last = nowMs();\n      s.raf = requestAnimationFrame(frame);\n    };\n    api.current = {\n      wake,\n      point: (f) => {\n        if (f === null) s.hT = 0;\n        else {\n          s.hT = 1;\n          s.hF = f;\n        }\n        wake();\n      },\n    };\n    draw(nowMs());\n    wake();\n    return () => {\n      mq.removeEventListener(\"change\", onMq);\n      io?.disconnect();\n      if (s.raf) cancelAnimationFrame(s.raf);\n      s.raf = 0;\n      api.current = null;\n    };\n  }, []);\n\n  const onPoint = (e: React.PointerEvent<HTMLDivElement>) => {\n    const el = e.currentTarget;\n    const r = el.getBoundingClientRect();\n    const k = r.width > 0 ? el.offsetWidth / r.width : 1;\n    const dx = (e.clientX - r.left) * k - el.offsetWidth / 2;\n    const dy = (e.clientY - r.top) * k - el.offsetHeight / 2;\n    const a = Math.atan2(dx, -dy) / TAU;\n    api.current?.point(((a % 1) + 1) % 1);\n  };\n  const onLeave = () => api.current?.point(null);\n\n  // Static gradient segments, drawn once: colour follows the angle around the ring.\n  const segs: React.ReactNode[] = [];\n  const stepA = TAU / SEGMENTS;\n  for (let i = 0; i < SEGMENTS; i++) {\n    const a0 = i * stepA - 0.004;\n    const a1 = (i + 1) * stepA + 0.004;\n    const x0 = r3(c + R * Math.sin(a0));\n    const y0 = r3(c - R * Math.cos(a0));\n    const x1 = r3(c + R * Math.sin(a1));\n    const y1 = r3(c - R * Math.cos(a1));\n    segs.push(\n      <path\n        key={i}\n        d={\"M\" + x0 + \" \" + y0 + \"A\" + r3(R) + \" \" + r3(R) + \" 0 0 1 \" + x1 + \" \" + y1}\n        fill=\"none\"\n        stroke={mix(fromRgb, toRgb, (i + 0.5) / SEGMENTS)}\n        strokeWidth={sw}\n      />,\n    );\n  }\n  const la = -0.5 * (sw / R) - 0.05;\n  const leadD =\n    \"M\" + r3(c + R * Math.sin(la)) + \" \" + r3(c - R * Math.cos(la)) + \"A\" + r3(R) + \" \" + r3(R) + \" 0 0 1 \" + r3(c + R * Math.sin(0.03)) + \" \" + r3(c - R * Math.cos(0.03));\n  const tickCount = 60;\n  const tickEls: React.ReactNode[] = [];\n  if (showTicks) {\n    for (let i = 0; i < tickCount; i++) {\n      tickEls.push(\n        <line\n          key={i}\n          x1={c}\n          y1={r3(c - rb - tickLen)}\n          x2={c}\n          y2={r3(c - rb)}\n          stroke=\"currentColor\"\n          strokeWidth={1.4}\n          strokeLinecap=\"round\"\n          opacity={0.16}\n          transform={\"rotate(\" + r3((i * 360) / tickCount) + \" \" + c + \" \" + c + \")\"}\n        />,\n      );\n    }\n  }\n\n  const fs = size * 0.27;\n  const name = label ?? \"Progress\";\n  return (\n    <div\n      ref={rootRef}\n      role=\"progressbar\"\n      aria-label={name}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={indeterminate ? undefined : Math.round(clamp(target, 0, 100))}\n      aria-busy={indeterminate || undefined}\n      onPointerMove={onPoint}\n      onPointerDown={onPoint}\n      onPointerLeave={onLeave}\n      onPointerUp={(e) => {\n        if (e.pointerType !== \"mouse\") onLeave();\n      }}\n      className={\"relative inline-flex items-center justify-center \" + (className ?? \"\")}\n      style={{ width: size, height: size, touchAction: \"manipulation\" }}\n    >\n      <svg width={size} height={size} viewBox={\"0 0 \" + size + \" \" + size} aria-hidden className=\"absolute inset-0 overflow-visible\">\n        <defs>\n          <mask id={maskId} maskUnits=\"userSpaceOnUse\" x={0} y={0} width={size} height={size}>\n            <circle\n              ref={maskCircle}\n              cx={c}\n              cy={c}\n              r={R}\n              fill=\"none\"\n              stroke=\"#fff\"\n              strokeWidth={sw}\n              strokeLinecap=\"round\"\n              strokeDasharray={\"0 \" + r3(C)}\n              transform={\"rotate(-90 \" + c + \" \" + c + \")\"}\n              opacity={0}\n            />\n          </mask>\n          <filter id={blurId} filterUnits=\"userSpaceOnUse\" x={-size * 0.3} y={-size * 0.3} width={size * 1.6} height={size * 1.6}>\n            <feGaussianBlur stdDeviation={Math.max(2, sw * 0.9)} />\n          </filter>\n        </defs>\n        {showTicks ? (\n          <g ref={tickG}>{tickEls}</g>\n        ) : (\n          <g ref={tickG} />\n        )}\n        <circle cx={c} cy={c} r={R} fill=\"none\" stroke=\"currentColor\" strokeOpacity={0.12} strokeWidth={sw} />\n        <g ref={arcG} className={indeterminate ? \"motion-reduce:animate-pulse\" : undefined}>\n          <g mask={\"url(#\" + maskId + \")\"} filter={\"url(#\" + blurId + \")\"} opacity={0.5}>\n            {segs}\n          </g>\n          <g mask={\"url(#\" + maskId + \")\"}>\n            {segs}\n            <path ref={leadRef} d={leadD} fill=\"none\" stroke={mix(fromRgb, toRgb, 0)} strokeWidth={sw} />\n          </g>\n          <circle ref={headHalo} cx={c} cy={c} r={sw} fill={from} filter={\"url(#\" + blurId + \")\"} opacity={0} />\n          <circle ref={headCore} cx={c} cy={c} r={sw * 0.2} fill=\"#fff\" opacity={0} />\n        </g>\n      </svg>\n      <div className=\"pointer-events-none relative flex flex-col items-center justify-center text-center\" style={{ fontSize: fs }}>\n        <span\n          ref={valueEl}\n          aria-hidden\n          className=\"inline-flex items-baseline font-semibold leading-none\"\n          style={{ letterSpacing: \"-0.03em\", fontFeatureSettings: '\"tnum\"' }}\n        >\n          <Column colRef={(el) => { cols.current[0] = el; }} stripRef={(el) => { strips.current[0] = el; }} />\n          <Column colRef={(el) => { cols.current[1] = el; }} stripRef={(el) => { strips.current[1] = el; }} />\n          <Column colRef={(el) => { cols.current[2] = el; }} stripRef={(el) => { strips.current[2] = el; }} />\n          <span style={{ fontSize: \"0.5em\", opacity: 0.5, marginLeft: \"0.08em\", fontWeight: 500 }}>%</span>\n        </span>\n        {label && size >= 96 ? (\n          <span\n            aria-hidden\n            className=\"mt-[0.35em] uppercase\"\n            style={{ fontSize: Math.max(9, size * 0.075) + \"px\", letterSpacing: \"0.16em\", opacity: 0.5, lineHeight: 1 }}\n          >\n            {label}\n          </span>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/progress-ring.tsx"}],"meta":{"tier":"free","tags":["progress","ring","svg","stats","gauge","loader","spring"],"href":"https://viberdy.dev/library/progress-ring","aiPrompt":"Create a single-file React + Tailwind component named ProgressRing (\"use client\", React only, no animation library, no CSS from the host: it inherits currentColor).\n\nProps (all optional): target 72 (0-100), size 120 (px, outer box incl. ticks), strokeWidth 10, duration 1.2 (seconds to settle), indeterminate false, ticks true (hidden under 72px), label (caption under the value, shown at size >= 96, also the aria-label), from \"#ff2d2d\" and to \"#ff8a3d\" (hex only, validated with /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i, else defaults), className on the outer element. Root: inline-flex, width and height = size, role=\"progressbar\" aria-valuemin 0 aria-valuemax 100 aria-valuenow (omitted when indeterminate, aria-busy instead).\n\nGeometry: ticks sit at the outer edge (60 lines, tickLen = clamp(size*0.05,3,8), every 15th is 1.45x long, 1.4 stroke, currentColor at 0.16 opacity), ring radius R = size/2 - 1.5 - bump - 0.2*tickLen - 4 - strokeWidth/2 (bump = 1.3*tickLen); without ticks R = size/2 - strokeWidth/2. Track: circle, currentColor stroke at 0.12 opacity. Gradient: 90 short SVG arc paths, each stroked with the from/to mix at its angle (angle 0 = 12 o'clock, clockwise), clipped by an SVG mask holding one white circle stroke with round caps, strokeDasharray \"<len> <circumference>\" and rotate(rot-90). Draw the segments twice: once through a Gaussian blur filter (stdDeviation max(2, 0.9*strokeWidth)) at 0.5 opacity for the glow, once sharp. Head: a blurred halo circle (r 0.85*strokeWidth, filled with the gradient colour at the head angle) plus a white core circle (r 0.2*strokeWidth) at the arc's leading end; both fade out as the value reaches 100.\n\nMotion: one requestAnimationFrame loop inside a mount effect writing attributes/styles straight to refs (no per-frame React state); it sleeps when the spring, indeterminate blend and hover are at rest and wakes on prop change or pointer. Value spring: semi-implicit Euler at 1/120 s substeps, omega = 5/duration, damping ratio 0.82 (a small overshoot), starts at 0 and springs to target when the ring first scrolls into view (IntersectionObserver threshold 0.3), then re-springs on every target change. Ticks: tick at fraction f is lit when it lies inside the arc, colour = gradient at f, opacity 0.3 + 0.7*(position along the arc)^2.2 (brighter toward the head), unlit 0.16 currentColor. Pointer (any type, down or move): hover weight eases at rate 9/s, ticks within a gaussian (sigma 0.045 of a turn) around the pointer angle extend by up to 1.3*tickLen and brighten by 0.55; the halo grows 1 + 0.5*hover. Indeterminate: blend factor eases at rate 7/s; arc length = 0.1 + 0.5*(0.5-0.5cos(2*pi*t/2.4)) of a turn, rotation 240deg/s.\n\nCentre value (aria-hidden): three digit columns (hundreds, tens, ones), semibold, size*0.27 px, letter-spacing -0.03em, each column 0.62em wide and 1.3em tall with a top/bottom mask fade, containing a strip of the digits 0-9 plus a trailing 0, translated by (position/11)*100%. Ones position = v mod 10; tens position = floor(v/10) + clamp(ones-9,0,1); hundreds shows a \"1\" when v >= 99. Tens width/opacity = clamp(v-9,0,1), hundreds = clamp(v-99,0,1). A \"%\" at 0.5em, 50% opacity. Label: uppercase, 0.16em tracking, 50% opacity. The value fades out when indeterminate.\n\nprefers-reduced-motion: no spring (jumps to target), no pulse; indeterminate becomes a static 28% arc with motion-reduce:animate-pulse. Unmount cancels the frame, observer and media-query listener. SSR: renders the empty ring at 0.","license":"https://viberdy.dev/license"}}