chunk-035-progress-circle.js

My friend, I’ll build this as a reusable RX Theme module: auto-detect progress circles, animate on scroll, support SVG circle, CSS variables, accessibility, counters, pause/replay, and WordPress-safe loading.

Below is an advanced, reusable, WordPress-safe file for:

assets/static-js/static-chunks-js/chunk-035-progress-circle.js

It supports:

  • SVG circular progress
  • Auto animation on scroll
  • Number counter animation
  • CSS variable control
  • Accessibility attributes
  • Dynamic colors by percent
  • Multiple circles on one page
  • Re-initialize after AJAX/load-more
  • Pause/resume/replay API
  • Reduced-motion support
  • Elementor/Gutenberg-friendly structure
/*!
 * RX Theme - Progress Circle Chunk
 * File: assets/static-js/static-chunks-js/chunk-035-progress-circle.js
 * Version: 1.0.0
 * Author: RxHarun
 *
 * Usage:
 * <div class="rx-progress-circle" data-progress="75" data-size="140" data-stroke="10">
 *   <span class="rx-progress-circle__value">0%</span>
 * </div>
 */

(function () {
  'use strict';

  const RXProgressCircle = {
    name: 'RXProgressCircle',
    version: '1.0.0',

    defaults: {
      selector: '.rx-progress-circle',
      valueSelector: '.rx-progress-circle__value',
      svgClass: 'rx-progress-circle__svg',
      trackClass: 'rx-progress-circle__track',
      barClass: 'rx-progress-circle__bar',
      initializedClass: 'rx-progress-circle--initialized',
      activeClass: 'rx-progress-circle--active',
      completeClass: 'rx-progress-circle--complete',

      size: 140,
      stroke: 10,
      progress: 0,
      duration: 1400,
      delay: 0,
      easing: 'easeOutCubic',

      trackColor: 'rgba(0,0,0,0.08)',
      barColor: 'currentColor',
      lowColor: '#ef4444',
      mediumColor: '#f59e0b',
      highColor: '#10b981',

      showPercent: true,
      suffix: '%',
      prefix: '',
      decimals: 0,
      startOnView: true,
      once: true,
      threshold: 0.25,
      rootMargin: '0px 0px -8% 0px',

      clockwise: true,
      roundLineCap: true,
      responsive: true,
      reducedMotionDuration: 1
    },

    easings: {
      linear(t) {
        return t;
      },
      easeOutCubic(t) {
        return 1 - Math.pow(1 - t, 3);
      },
      easeInOutCubic(t) {
        return t < 0.5
          ? 4 * t * t * t
          : 1 - Math.pow(-2 * t + 2, 3) / 2;
      },
      easeOutQuart(t) {
        return 1 - Math.pow(1 - t, 4);
      }
    },

    items: new Map(),
    observer: null,
    resizeTimer: null,

    init(customOptions = {}) {
      this.options = Object.assign({}, this.defaults, customOptions);

      const elements = document.querySelectorAll(this.options.selector);

      if (!elements.length) {
        return;
      }

      elements.forEach((element) => {
        this.setup(element);
      });

      this.observe();
      this.bindGlobalEvents();
      document.documentElement.classList.add('rx-progress-circle-ready');
    },

    setup(element) {
      if (!element || element.classList.contains(this.options.initializedClass)) {
        return;
      }

      const config = this.getConfig(element);
      const geometry = this.getGeometry(config);

      element.style.setProperty('--rx-progress-size', `${config.size}px`);
      element.style.setProperty('--rx-progress-stroke', `${config.stroke}px`);
      element.style.setProperty('--rx-progress-track-color', config.trackColor);
      element.style.setProperty('--rx-progress-bar-color', this.getSmartColor(config));

      element.setAttribute('role', 'progressbar');
      element.setAttribute('aria-valuemin', '0');
      element.setAttribute('aria-valuemax', '100');
      element.setAttribute('aria-valuenow', '0');

      const svg = this.createSVG(config, geometry);
      const existingSVG = element.querySelector(`.${this.options.svgClass}`);

      if (!existingSVG) {
        element.prepend(svg);
      }

      let valueElement = element.querySelector(this.options.valueSelector);

      if (!valueElement) {
        valueElement = document.createElement('span');
        valueElement.className = this.options.valueSelector.replace('.', '');
        element.appendChild(valueElement);
      }

      valueElement.textContent = this.formatValue(0, config);

      const bar = element.querySelector(`.${this.options.barClass}`);

      if (bar) {
        bar.style.strokeDasharray = geometry.circumference;
        bar.style.strokeDashoffset = geometry.circumference;
      }

      element.classList.add(this.options.initializedClass);

      this.items.set(element, {
        element,
        config,
        geometry,
        svg,
        bar,
        valueElement,
        progress: 0,
        started: false,
        completed: false,
        paused: false,
        requestId: null,
        startTime: null,
        pauseTime: null
      });

      if (!config.startOnView) {
        this.play(element);
      }
    },

    getConfig(element) {
      const data = element.dataset || {};

      const size = this.toNumber(data.size, this.options.size);
      const stroke = this.toNumber(data.stroke, this.options.stroke);
      const progress = this.clamp(this.toNumber(data.progress, this.options.progress), 0, 100);
      const duration = this.toNumber(data.duration, this.options.duration);
      const delay = this.toNumber(data.delay, this.options.delay);

      return {
        selector: this.options.selector,
        size,
        stroke,
        progress,
        duration,
        delay,
        easing: data.easing || this.options.easing,

        trackColor: data.trackColor || this.options.trackColor,
        barColor: data.barColor || this.options.barColor,
        lowColor: data.lowColor || this.options.lowColor,
        mediumColor: data.mediumColor || this.options.mediumColor,
        highColor: data.highColor || this.options.highColor,

        showPercent: this.toBoolean(data.showPercent, this.options.showPercent),
        suffix: typeof data.suffix === 'string' ? data.suffix : this.options.suffix,
        prefix: typeof data.prefix === 'string' ? data.prefix : this.options.prefix,
        decimals: this.toNumber(data.decimals, this.options.decimals),

        startOnView: this.toBoolean(data.startOnView, this.options.startOnView),
        once: this.toBoolean(data.once, this.options.once),
        threshold: this.toNumber(data.threshold, this.options.threshold),
        rootMargin: data.rootMargin || this.options.rootMargin,

        clockwise: this.toBoolean(data.clockwise, this.options.clockwise),
        roundLineCap: this.toBoolean(data.roundLineCap, this.options.roundLineCap),
        responsive: this.toBoolean(data.responsive, this.options.responsive)
      };
    },

    getGeometry(config) {
      const center = config.size / 2;
      const radius = (config.size - config.stroke) / 2;
      const circumference = 2 * Math.PI * radius;

      return {
        center,
        radius,
        circumference
      };
    },

    createSVG(config, geometry) {
      const namespace = 'http://www.w3.org/2000/svg';

      const svg = document.createElementNS(namespace, 'svg');
      svg.setAttribute('class', this.options.svgClass);
      svg.setAttribute('width', config.size);
      svg.setAttribute('height', config.size);
      svg.setAttribute('viewBox', `0 0 ${config.size} ${config.size}`);
      svg.setAttribute('aria-hidden', 'true');
      svg.setAttribute('focusable', 'false');

      const track = document.createElementNS(namespace, 'circle');
      track.setAttribute('class', this.options.trackClass);
      track.setAttribute('cx', geometry.center);
      track.setAttribute('cy', geometry.center);
      track.setAttribute('r', geometry.radius);
      track.setAttribute('fill', 'none');
      track.setAttribute('stroke-width', config.stroke);
      track.setAttribute('stroke', config.trackColor);

      const bar = document.createElementNS(namespace, 'circle');
      bar.setAttribute('class', this.options.barClass);
      bar.setAttribute('cx', geometry.center);
      bar.setAttribute('cy', geometry.center);
      bar.setAttribute('r', geometry.radius);
      bar.setAttribute('fill', 'none');
      bar.setAttribute('stroke-width', config.stroke);
      bar.setAttribute('stroke', this.getSmartColor(config));

      if (config.roundLineCap) {
        bar.setAttribute('stroke-linecap', 'round');
      }

      const rotate = config.clockwise ? '-90' : '90';
      const scale = config.clockwise ? '1 1' : '-1 1';

      bar.style.transformOrigin = '50% 50%';
      bar.style.transform = `rotate(${rotate}deg) scale(${scale})`;

      svg.appendChild(track);
      svg.appendChild(bar);

      return svg;
    },

    observe() {
      if (!('IntersectionObserver' in window)) {
        this.items.forEach((item) => {
          if (item.config.startOnView) {
            this.play(item.element);
          }
        });
        return;
      }

      if (this.observer) {
        this.observer.disconnect();
      }

      this.observer = new IntersectionObserver(
        (entries) => {
          entries.forEach((entry) => {
            const element = entry.target;
            const item = this.items.get(element);

            if (!item) {
              return;
            }

            if (entry.isIntersecting) {
              this.play(element);

              if (item.config.once && this.observer) {
                this.observer.unobserve(element);
              }
            } else if (!item.config.once) {
              this.reset(element);
            }
          });
        },
        {
          threshold: this.options.threshold,
          rootMargin: this.options.rootMargin
        }
      );

      this.items.forEach((item) => {
        if (item.config.startOnView) {
          this.observer.observe(item.element);
        }
      });
    },

    play(element) {
      const item = this.items.get(element);

      if (!item || item.started && item.config.once) {
        return;
      }

      if (item.requestId) {
        cancelAnimationFrame(item.requestId);
      }

      item.started = true;
      item.paused = false;
      item.completed = false;
      item.startTime = null;

      element.classList.add(this.options.activeClass);
      element.classList.remove(this.options.completeClass);

      const prefersReducedMotion = window.matchMedia &&
        window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      const duration = prefersReducedMotion
        ? this.options.reducedMotionDuration
        : item.config.duration;

      window.setTimeout(() => {
        const animate = (timestamp) => {
          if (item.paused) {
            item.pauseTime = timestamp;
            return;
          }

          if (!item.startTime) {
            item.startTime = timestamp;
          }

          const elapsed = timestamp - item.startTime;
          const rawProgress = this.clamp(elapsed / duration, 0, 1);
          const easingFunction = this.easings[item.config.easing] || this.easings.easeOutCubic;
          const easedProgress = easingFunction(rawProgress);

          const currentValue = item.config.progress * easedProgress;

          this.update(item, currentValue);

          if (rawProgress < 1) {
            item.requestId = requestAnimationFrame(animate);
          } else {
            this.complete(item);
          }
        };

        item.requestId = requestAnimationFrame(animate);
      }, item.config.delay);
    },

    pause(element) {
      const item = this.items.get(element);

      if (!item || item.paused) {
        return;
      }

      item.paused = true;

      if (item.requestId) {
        cancelAnimationFrame(item.requestId);
        item.requestId = null;
      }

      item.element.classList.add('rx-progress-circle--paused');
    },

    resume(element) {
      const item = this.items.get(element);

      if (!item || !item.paused) {
        return;
      }

      item.paused = false;
      item.element.classList.remove('rx-progress-circle--paused');

      const currentProgress = item.progress;
      const remainingProgress = item.config.progress - currentProgress;
      const remainingRatio = remainingProgress / item.config.progress;
      const remainingDuration = item.config.duration * remainingRatio;

      const startValue = currentProgress;
      let startTime = null;

      const animate = (timestamp) => {
        if (!startTime) {
          startTime = timestamp;
        }

        const elapsed = timestamp - startTime;
        const rawProgress = this.clamp(elapsed / remainingDuration, 0, 1);
        const easingFunction = this.easings[item.config.easing] || this.easings.easeOutCubic;
        const easedProgress = easingFunction(rawProgress);

        const currentValue = startValue + remainingProgress * easedProgress;

        this.update(item, currentValue);

        if (rawProgress < 1) {
          item.requestId = requestAnimationFrame(animate);
        } else {
          this.complete(item);
        }
      };

      item.requestId = requestAnimationFrame(animate);
    },

    reset(element) {
      const item = this.items.get(element);

      if (!item) {
        return;
      }

      if (item.requestId) {
        cancelAnimationFrame(item.requestId);
      }

      item.progress = 0;
      item.started = false;
      item.completed = false;
      item.paused = false;
      item.startTime = null;
      item.pauseTime = null;

      this.update(item, 0);

      item.element.classList.remove(
        this.options.activeClass,
        this.options.completeClass,
        'rx-progress-circle--paused'
      );
    },

    replay(element) {
      this.reset(element);
      this.play(element);
    },

    update(item, value) {
      const safeValue = this.clamp(value, 0, item.config.progress);
      const percentage = safeValue / 100;
      const offset = item.geometry.circumference * (1 - percentage);

      item.progress = safeValue;

      if (item.bar) {
        item.bar.style.strokeDashoffset = offset;
      }

      if (item.valueElement) {
        item.valueElement.textContent = this.formatValue(safeValue, item.config);
      }

      item.element.setAttribute('aria-valuenow', String(Math.round(safeValue)));
      item.element.style.setProperty('--rx-progress-current', safeValue);
    },

    complete(item) {
      this.update(item, item.config.progress);

      item.completed = true;
      item.requestId = null;

      item.element.classList.remove(this.options.activeClass);
      item.element.classList.add(this.options.completeClass);

      const event = new CustomEvent('rxProgressCircleComplete', {
        detail: {
          element: item.element,
          progress: item.config.progress
        }
      });

      item.element.dispatchEvent(event);
    },

    refresh() {
      const elements = document.querySelectorAll(this.options.selector);

      elements.forEach((element) => {
        if (!this.items.has(element)) {
          this.setup(element);
        }
      });

      this.observe();
    },

    destroy() {
      if (this.observer) {
        this.observer.disconnect();
      }

      this.items.forEach((item) => {
        if (item.requestId) {
          cancelAnimationFrame(item.requestId);
        }

        item.element.classList.remove(
          this.options.initializedClass,
          this.options.activeClass,
          this.options.completeClass,
          'rx-progress-circle--paused'
        );
      });

      this.items.clear();
      document.documentElement.classList.remove('rx-progress-circle-ready');
    },

    bindGlobalEvents() {
      if (this.globalEventsBound) {
        return;
      }

      this.globalEventsBound = true;

      window.addEventListener('resize', () => {
        clearTimeout(this.resizeTimer);

        this.resizeTimer = window.setTimeout(() => {
          this.handleResize();
        }, 180);
      });

      document.addEventListener('DOMContentLoaded', () => {
        this.refresh();
      });

      window.addEventListener('load', () => {
        this.refresh();
      });

      document.addEventListener('rxThemeAjaxLoaded', () => {
        this.refresh();
      });

      document.addEventListener('rxProgressCircleRefresh', () => {
        this.refresh();
      });
    },

    handleResize() {
      this.items.forEach((item) => {
        if (!item.config.responsive) {
          return;
        }

        const parentWidth = item.element.parentElement
          ? item.element.parentElement.clientWidth
          : item.config.size;

        const newSize = Math.min(item.config.size, parentWidth);

        if (newSize <= 0 || newSize === item.config.size) {
          return;
        }

        item.config.size = newSize;
        item.geometry = this.getGeometry(item.config);

        const svg = item.element.querySelector(`.${this.options.svgClass}`);
        const circles = svg ? svg.querySelectorAll('circle') : [];

        if (svg) {
          svg.setAttribute('width', newSize);
          svg.setAttribute('height', newSize);
          svg.setAttribute('viewBox', `0 0 ${newSize} ${newSize}`);
        }

        circles.forEach((circle) => {
          circle.setAttribute('cx', item.geometry.center);
          circle.setAttribute('cy', item.geometry.center);
          circle.setAttribute('r', item.geometry.radius);
        });

        if (item.bar) {
          item.bar.style.strokeDasharray = item.geometry.circumference;
        }

        this.update(item, item.progress);
      });
    },

    getSmartColor(config) {
      if (config.barColor && config.barColor !== 'auto') {
        return config.barColor;
      }

      if (config.progress < 40) {
        return config.lowColor;
      }

      if (config.progress < 75) {
        return config.mediumColor;
      }

      return config.highColor;
    },

    formatValue(value, config) {
      const fixedValue = Number(value).toFixed(config.decimals);
      return `${config.prefix}${fixedValue}${config.showPercent ? config.suffix : ''}`;
    },

    toNumber(value, fallback) {
      const number = Number(value);
      return Number.isFinite(number) ? number : fallback;
    },

    toBoolean(value, fallback) {
      if (typeof value === 'undefined') {
        return fallback;
      }

      if (value === 'true' || value === '1' || value === true) {
        return true;
      }

      if (value === 'false' || value === '0' || value === false) {
        return false;
      }

      return fallback;
    },

    clamp(value, min, max) {
      return Math.min(Math.max(value, min), max);
    }
  };

  window.RXProgressCircle = RXProgressCircle;

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => {
      RXProgressCircle.init();
    });
  } else {
    RXProgressCircle.init();
  }
})();

Add this CSS in your theme CSS

.rx-progress-circle {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rx-progress-size, 140px);
  height: var(--rx-progress-size, 140px);
  color: var(--rx-progress-bar-color, currentColor);
  line-height: 1;
}

.rx-progress-circle__svg {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

.rx-progress-circle__track {
  stroke: var(--rx-progress-track-color, rgba(0, 0, 0, 0.08));
}

.rx-progress-circle__bar {
  stroke: var(--rx-progress-bar-color, currentColor);
  transition: stroke 0.3s ease;
  will-change: stroke-dashoffset;
}

.rx-progress-circle__value {
  position: relative;
  z-index: 2;
  font-size: clamp(18px, 2vw, 30px);
  font-weight: 700;
  color: currentColor;
}

.rx-progress-circle--complete .rx-progress-circle__value {
  transform: scale(1.03);
}

@media (prefers-reduced-motion: reduce) {
  .rx-progress-circle__bar,
  .rx-progress-circle__value {
    transition: none;
  }
}

HTML example

<div
  class="rx-progress-circle"
  data-progress="85"
  data-size="150"
  data-stroke="12"
  data-duration="1600"
  data-bar-color="auto"
>
  <span class="rx-progress-circle__value">0%</span>
</div>

Advanced examples

<div
  class="rx-progress-circle"
  data-progress="62"
  data-size="120"
  data-stroke="8"
  data-duration="1200"
  data-delay="300"
  data-bar-color="#2563eb"
  data-track-color="rgba(37, 99, 235, 0.12)"
>
  <span class="rx-progress-circle__value">0%</span>
</div>
<div
  class="rx-progress-circle"
  data-progress="95"
  data-size="180"
  data-stroke="14"
  data-easing="easeOutQuart"
  data-prefix=""
  data-suffix="%"
  data-decimals="0"
>
  <span class="rx-progress-circle__value">0%</span>
</div>

Optional API usage

// Refresh after AJAX content load
window.RXProgressCircle.refresh();

// Replay one circle
const circle = document.querySelector('.rx-progress-circle');
window.RXProgressCircle.replay(circle);

// Pause
window.RXProgressCircle.pause(circle);

// Resume
window.RXProgressCircle.resume(circle);

For RX Theme, enqueue this file with defer because it is safe after DOM parsing.

Leave a Reply

Your email address will not be published. Required fields are marked *