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.