How to Build a Circular Countdown Progress Bar with Pure CSS and JavaScript
This step‑by‑step tutorial shows how to create a circular countdown timer by constructing a fixed container, drawing light and dark arcs with CSS, adding a masking circle, and animating the progress using a small JavaScript snippet that updates the rotation and countdown display.
Introduction
In a recent project I needed a circular countdown progress bar. This article walks through building it from scratch using only HTML, CSS and a little JavaScript.
Implementation Steps
Add Container
Make the outer container fixed so it can be placed anywhere on the page.
<code><div class="task-container"></div></code>Corresponding CSS:
<code>.task-container {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
width: 65px;
height: 65px;
display: flex;
justify-content: center;
align-items: center;
}</code>Draw Base Circle
Add a concentric circle to serve as the background of the timer.
<code><div class="task-container">
<div class="task-cicle"></div>
</div></code> <code>.task-container {
/* same styles as above */
}
.task-cicle {
display: flex;
justify-content: center;
align-items: center;
width: 53px;
height: 53px;
border-radius: 50%;
background: #FFFFFF;
box-shadow: 0px 0px 12px 0px rgba(0,0,0,0.05);
}</code>Draw Right Arc
Use a right‑half rectangle and set only the top and right borders to create the right arc.
<code><div class="task-container">
<div class="task-cicle">
<div class="task-inner">
<div class="right-cicle">
<div class="cicle-progress cicle1-inner"></div>
</div>
</div>
</div>
</div></code> <code>.right-cicle {
width: 23px;
height: 46px;
position: absolute;
top: 0;
right: 0;
overflow: hidden;
}
.cicle-progress {
position: absolute;
top: 0;
width: 46px;
height: 46px;
border: 3px solid transparent;
box-sizing: border-box;
border-radius: 50%;
}
.cicle1-inner {
left: -23px;
border-right: 3px solid #e0e0e0;
border-top: 3px solid #e0e0e0;
transform: rotate(-15deg);
}</code>Draw Left Arc
Apply the same principle to the left side, setting only the top and left borders.
<code><div class="task-container">
<div class="task-cicle">
<div class="task-inner">
...
<div class="left-cicle">
<div class="cicle-progress cicle2-inner"></div>
</div>
</div>
</div>
</div></code> <code>.left-cicle {
width: 23px;
height: 46px;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
}
.cicle2-inner {
left: 0;
border-left: 3px solid #e0e0e0;
border-top: 3px solid #e0e0e0;
transform: rotate(15deg);
}</code>Draw Right Progress Bar
Set the right‑half progress bar with a bright border and rotate it from -135° to -15°.
<code>.cicle3-inner {
left: -23px;
border-right: 3px solid #feca02;
border-top: 3px solid #feca02;
transform: rotate(-135deg);
}</code>Draw Left Progress Bar
Set the left‑half progress bar with a bright border and rotate it from 195° to 315°.
<code>.cicle4-inner {
left: 0;
border-left: 3px solid #feca02;
border-top: 3px solid #feca02;
transform: rotate(195deg);
}</code>Add Masking Circle
Place a larger concentric circle to hide the tiny tail that appears after clipping.
<code>.mask-inner {
position: absolute;
left: 0;
top: 0;
width: 39px;
height: 39px;
border: 4px solid transparent;
border-radius: 50%;
border-left: 4px solid #FFFFFF;
border-top: 4px solid #FFFFFF;
transform: rotate(195deg);
}</code>JavaScript Animation
The script calculates the rotation per second, creates keyframe rules on the fly, and updates the countdown text. Clicking the timer pauses or resumes the animation.
<code>const rightCicle = document.getElementById('rightCicle');
const leftCicle = document.getElementById('leftCicle');
const timeDom = document.getElementById('time');
let isStop = false;
let timer;
const totalTime = 10; // total seconds
const halfTime = totalTime / 2;
const initRightDeg = -135;
const initLeftDeg = 195;
const halfCicle = 120; // degrees each side rotates
const perDeg = 120 / halfTime; // degrees per second
let inittime = 10;
let begTime;
let stopTime;
function run() {
const time = inittime;
let animation;
if (time > halfTime) {
animation = `
@keyframes task-left {
0% { transform: rotate(${initLeftDeg + (totalTime - time) * perDeg}deg); }
100% { transform: rotate(${initLeftDeg + halfCicle}deg); }
}
.task-left { animation-name: task-left; animation-duration: ${time - halfTime}s; animation-timing-function: linear; animation-fill-mode: forwards; }
@keyframes task-right {
0% { transform: rotate(${initRightDeg}deg); }
100% { transform: rotate(${initRightDeg + halfCicle}deg); }
}
.task-right { animation-name: task-right; animation-duration: ${halfTime}s; animation-timing-function: linear; animation-delay: ${time - halfTime}s; animation-fill-mode: forwards; }
`;
} else {
animation = `
@keyframes task-left {
0% { transform: rotate(${initLeftDeg + halfCicle}deg); }
100% { transform: rotate(${initLeftDeg + halfCicle}deg); }
}
.task-left { animation-name: task-left; animation-duration: 0s; animation-fill-mode: forwards; }
@keyframes task-right {
0% { transform: rotate(${initRightDeg + (halfTime - time) * perDeg}deg); }
100% { transform: rotate(${initRightDeg + halfCicle}deg); }
}
.task-right { animation-name: task-right; animation-duration: ${time}s; animation-timing-function: linear; animation-fill-mode: forwards; }
`;
}
animation += `.stop { animation-play-state: paused; } .run { animation-play-state: running; }`;
const styleDom = document.createElement('style');
styleDom.type = 'text/css';
styleDom.innerHTML = animation;
document.getElementsByTagName('head')[0].appendChild(styleDom);
leftCicle.classList.add('task-left');
rightCicle.classList.add('task-right');
begTime = Date.now();
countDown();
}
function countDown() {
if (begTime && stopTime) {
const runtime = stopTime - begTime;
if (runtime % 1000 > 500) { inittime -= 1; }
}
begTime = Date.now();
timeDom.innerText = `${inittime}秒后获得 `;
timer = setInterval(() => {
inittime -= 1;
timeDom.innerText = `${inittime}秒后获得 `;
if (inittime <= 0) clearInterval(timer);
}, 1000);
}
timeDom.addEventListener('click', () => {
if (isStop) {
isStop = false;
countDown();
leftCicle.classList.remove('stop'); leftCicle.classList.add('run');
rightCicle.classList.remove('stop'); rightCicle.classList.add('run');
} else {
stopTime = Date.now();
isStop = true;
clearInterval(timer);
leftCicle.classList.remove('run'); leftCicle.classList.add('stop');
rightCicle.classList.remove('run'); rightCicle.classList.add('stop');
}
}, false);
run();
</code>Conclusion
The light arc and bright progress bar involve several layers of clipped circles. By removing clipping temporarily you can see each layer's shape, which helps understand the construction. The final result is a smooth, animated circular countdown timer.
WecTeam
WecTeam (维C团) is the front‑end technology team of JD.com’s Jingxi business unit, focusing on front‑end engineering, web performance optimization, mini‑program and app development, serverless, multi‑platform reuse, and visual building.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.