Building a countdown timer from scratch
Countdown timers often get pulled in as plugins, but the plain stack of HTML, CSS and JavaScript is enough. The timer below shows the initial time remaining, formats it as MM:SS, derives the remaining time from how much has already elapsed, shifts color as the deadline approaches, and visualizes progress with an animated ring.
Markup, styles and the time label
The base structure is an svg holding a circle that draws the timer ring, plus a for the numeric readout. The markup is written in JavaScript and injected into the DOM through the #app element, so it could instead live in an HTML file if preferred.
document.getElementById("app").innerHTML = `
<div class="base-timer">
<svg class="base-timer__svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g class="base-timer__circle">
<circle class="base-timer__path-elapsed" cx="50" cy="50" r="45" />
</g>
</svg>
<span>
<!-- Remaining time label -->
</span>
</div>
`;
The styling sets the timer's size, strips the fill and stroke from the circle wrapper (keeping the shape while letting the elapsed portion show through), and defines the ring's width and color.
/* Sets the containers height and width */
.base-timer {
position: relative;
height: 300px;
width: 300px;
}
/* Removes SVG styling that would hide the time label */
.base-timer__circle {
fill: none;
stroke: none;
}
/* The SVG path that displays the timer's progress */
.base-timer__path-elapsed {
stroke-width: 7px;
stroke: grey;
}
That produces the starting template.

Filling the empty span is a formatTimeLeft method that produces the MM:SS string.
function formatTimeLeft(time) {
// The largest round integer less than or equal to the result of time divided being by 60.
const minutes = Math.floor(time / 60);
// Seconds are the remainder of the time divided by 60 (modulus operator)
let seconds = time % 60;
// If the value of seconds is less than 10, then display seconds with a leading zero
if (seconds < 10) {
seconds = `0${seconds}`;
}
// The output in MM:SS format
return `${minutes}:${seconds}`;
}
Called from the template:
document.getElementById("app").innerHTML = `
<div class="base-timer">
<svg class="base-timer__svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g class="base-timer__circle">
<circle class="base-timer__path-elapsed" cx="50" cy="50" r="45"></circle>
</g>
</svg>
<span id="base-timer-label" class="base-timer__label">
${formatTime(timeLeft)}
</span>
</div>
`
Displaying the value inside the ring needs a few style adjustments.
.base-timer__label {
position: absolute;
/* Size should match the parent container */
width: 300px;
height: 300px;
/* Keep the label aligned to the top */
top: 0;
/* Create a flexible box that centers content vertically and horizontally */
display: flex;
align-items: center;
justify-content: center;
/* Sort of an arbitrary number; adjust to your liking */
font-size: 48px;
}
The timeLeft value the template reads doesn't exist yet, so it is created and initialized to the time limit.
// Start with an initial value of 20 seconds
const TIME_LIMIT = 20;
// Initially, no time has passed, but this will count up
// and subtract from the TIME_LIMIT
let timePassed = 0;
let timeLeft = TIME_LIMIT;

Driving the countdown with an interval
Two values are in play: timeLimit for the initial duration and timePassed for elapsed time once counting begins. Counting down means incrementing timePassed by one unit per second and recomputing timeLeft from it, which setInterval handles.
A startTimer method sets the counter interval, increments timePassed each second, recomputes timeLeft, and writes the new label value into the template. The interval object reference is kept in a timerInterval variable so it can be cleared later.
let timerInterval = null;
document.getElementById("app").innerHTML = `...`
function startTimer() {
timerInterval = setInterval(() => {
// The amount of time passed increments by one
timePassed = timePassed += 1;
timeLeft = TIME_LIMIT - timePassed;
// The time left label is updated
document.getElementById("base-timer-label").innerHTML = formatTime(timeLeft);
}, 1000);
}
The method then has to actually be invoked; the timer starts immediately on load.
document.getElementById("app").innerHTML = `...`
startTimer();

Layering a second ring
Animating the passage of time requires a second layer on the ring. A green ring sits on top of the original gray one and animates to uncover the gray beneath it as time runs out — effectively a progress bar. That means adding a path element to the SVG, giving it an initial color for the remaining time, and styling the circular path so it matches the original gray ring.
document.getElementById("app").innerHTML = `
<div class="base-timer">
<svg class="base-timer__svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g class="base-timer__circle">
<circle class="base-timer__path-elapsed" cx="50" cy="50" r="45"></circle>
<path
id="base-timer-path-remaining"
stroke-dasharray="283"
class="base-timer__path-remaining ${remainingPathColor}"
d="
M 50, 50
m -45, 0
a 45,45 0 1,0 90,0
a 45,45 0 1,0 -90,0
"
></path>
</g>
</svg>
<span id="base-timer-label" class="base-timer__label">
${formatTime(timeLeft)}
</span>
</div>
`;
const COLOR_CODES = {
info: {
color: "green"
}
};
let remainingPathColor = COLOR_CODES.info.color;
The critical detail is matching stroke-width to the original ring and setting the transition duration to one second, so the animation stays smooth and stays in sync with the numeric label.
.base-timer__path-remaining {
/* Just as thick as the original ring */
stroke-width: 7px;
/* Rounds the line endings to create a seamless circle */
stroke-linecap: round;
/* Makes sure the animation starts at the top of the circle */
transform: rotate(90deg);
transform-origin: center;
/* One second aligns with the speed of the countdown timer */
transition: 1s linear all;
/* Allows the ring to change color when the color value updates */
stroke: currentColor;
}
.base-timer__svg {
/* Flips the svg and makes the animation to move left-to-right */
transform: scaleX(-1);
}
The stroke covers the timer ring correctly at this point, but nothing reveals the ring yet.

Animating the length of the remaining-time line relies on stroke-dasharray, the property used to create the illusion of an element drawing itself.

With a single-digit value, stroke-dasharray slices the remaining-time ring into equal-length sections, where each section's length is the time-remaining value. The name "dasharray" hints that multiple values are allowed. With two numbers, 10 and 30, the first section (remaining time) is 10 and the second (passed time) is 30.

stroke-dasharray: 10 30For the timer, the first value is the remaining time and the second marks elapsed time. Initially the ring should cover the full circle, meaning remaining time equals the ring's length. That length is the arc length, computed with a bit of geometry.
Length = 2πr = 2 * π * 45 = 282,6
That's the value used when the ring first mounts.

stroke-dasharray: 283 283Only the first value of the array needs manipulation from here on.

Two methods handle it: one computes what fraction of the initial time is left, the other calculates the stroke-dasharray value and updates the <path> element representing the remaining time.
// Divides time left by the defined time limit.
function calculateTimeFraction() {
return timeLeft / TIME_LIMIT;
}
// Update the dasharray value as time passes, starting with 283
function setCircleDasharray() {
const circleDasharray = `${(
calculateTimeFraction() * FULL_DASH_ARRAY
).toFixed(0)} 283`;
document
.getElementById("base-timer-path-remaining")
.setAttribute("stroke-dasharray", circleDasharray);
}
The path must also be updated every second, so setCircleDasharray is called inside timerInterval.
function startTimer() {
timerInterval = setInterval(() => {
timePassed = timePassed += 1;
timeLeft = TIME_LIMIT - timePassed;
document.getElementById("base-timer-label").innerHTML = formatTime(timeLeft);
setCircleDasharray();
}, 1000);
}

Look closely near the end and there's a one-second lag: at zero, a sliver of ring is still visible. The cause is the animation duration of one second — when remaining time hits zero, the ring still takes a full second to animate down. The fix is to shorten the ring gradually during the countdown, inside calculateTimeFraction.

function calculateTimeFraction() {
const rawTimeFraction = timeLeft / TIME_LIMIT;
return rawTimeFraction - (1 / TIME_LIMIT) * (1 - rawTimeFraction);
}
That resolves it.

Shifting color as time runs out
The remaining feature is color feedback: the progress indicator should change at certain points to signal that time is nearly up. Two thresholds mark the warning and alert states, each with its own color — green first, then orange for warning, then red as the end nears — and a method checks whether a threshold has been exceeded and swaps the progress color accordingly.
// Warning occurs at 10s
const WARNING_THRESHOLD = 10;
// Alert occurs at 5s
const ALERT_THRESHOLD = 5;
const COLOR_CODES = {
info: {
color: "green"
},
warning: {
color: "orange",
threshold: WARNING_THRESHOLD
},
alert: {
color: "red",
threshold: ALERT_THRESHOLD
}
};
In practice this means removing one CSS class when the timer crosses a point and adding another in its place, so those classes have to be defined.
function setRemainingPathColor(timeLeft) {
const { alert, warning, info } = COLOR_CODES;
// If the remaining time is less than or equal to 5, remove the "warning" class and apply the "alert" class.
if (timeLeft <= alert.threshold) {
document
.getElementById("base-timer-path-remaining")
.classList.remove(warning.color);
document
.getElementById("base-timer-path-remaining")
.classList.add(alert.color);
// If the remaining time is less than or equal to 10, remove the base color and apply the "warning" class.
} else if (timeLeft <= warning.threshold) {
document
.getElementById("base-timer-path-remaining")
.classList.remove(info.color);
document
.getElementById("base-timer-path-remaining")
.classList.add(warning.color);
}
}
.base-timer__path-remaining.green {
color: rgb(65, 184, 131);
}
.base-timer__path-remaining.orange {
color: orange;
}
.base-timer__path-remaining.red {
color: red;
}
With that, the timer is complete.



