Build Your Own Countdown Timer with HTML, CSS and JavaScript
A complete countdown in about 30 lines of vanilla JavaScript - no dependencies. Plus the timezone parsing trap and the drift problem that break most tutorials you will find.

A countdown timer is one of those components that looks trivial and has a surprising number of ways to get it wrong. The arithmetic is easy. What catches people out is date parsing that silently produces a different deadline in every timezone, a timer that drifts out of sync during a long campaign, and the behaviour at zero that nobody tests until it happens in production on a Friday night.
This JavaScript countdown timer tutorial builds a working timer from scratch with no libraries, then covers each of those problems explicitly. By the end you will have custom countdown clock code you fully own and understand. If you just want a timer on a page without writing code, our guide to adding a countdown timer to any website is the faster route - this one is for building it yourself.
Live demo
Here is the finished timer from this tutorial - four colour-coded cards, a heading and a plain-text deadline - running live. Switch between the HTML, CSS, JS and Result tabs, or open it on CodePen to fork it and change anything you like.
See the Pen Countdown Timer by rija (@rija-io) on CodePen.
The demo uses three files - index.html, style.css and script.js - and the steps below walk through each one.
Step 1: The markup
Start with a heading, a short subheading, a container with four labelled units, and the deadline in plain text. Keeping each number in its own element means the update function only has to write text, never rebuild HTML - which is both faster and avoids a class of bugs where the browser re-parses markup 86,400 times a day.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Countdown Timer</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="wrap">
<h1 class="title">Year-End Offer Ends In</h1>
<p class="subtitle">Plain HTML, CSS and JavaScript - zero dependencies</p>
<div id="countdown" class="countdown">
<div class="unit"><span id="days">00</span><small>days</small></div>
<div class="unit"><span id="hours">00</span><small>hours</small></div>
<div class="unit"><span id="minutes">00</span><small>minutes</small></div>
<div class="unit"><span id="seconds">00</span><small>seconds</small></div>
</div>
<p class="deadline">Offer ends 31 December 2026 at 11:59pm ET</p>
</div>
<script src="script.js"></script>
</body>
</html>
The <link> in the head loads the stylesheet and the <script src> just before </body> loads the logic once the elements exist. Leave either one out and you get unstyled text stuck at all zeros - the most common reason a copied timer "does not work".
That last line is not decoration. The deadline should exist as plain text somewhere on the page, for reasons covered in the accessibility section below. If you drop the timer into an existing page that already has an h1, change the heading to an h2 so the page keeps a single main heading.
Step 2: The CSS
This lays the four units out as square cards on a single row, gives each one its own light background colour, and scales everything down smoothly on narrow screens. It is a starting point - swap in your own colours, font and spacing.
@import url('https://fonts.googleapis.com/css2?family=Kanit:wght@300;500;800&display=swap');
* { box-sizing: border-box; margin: 0; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #e8ebee;
font-family: 'Kanit', system-ui, sans-serif;
color: #06262d;
}
.wrap {
width: 100%;
max-width: 760px;
margin: 0 auto;
text-align: center;
}
.title {
font-size: clamp(1.6rem, 5vw, 2.6rem);
font-weight: 800;
line-height: 1.1;
margin-bottom: 8px;
}
.subtitle {
font-size: clamp(0.9rem, 2.2vw, 1.1rem);
font-weight: 300;
color: #4b5a60;
margin-bottom: 28px;
}
.countdown {
display: flex;
gap: clamp(8px, 2vw, 24px);
justify-content: center;
flex-wrap: nowrap;
}
.countdown .unit {
flex: 1 1 0;
max-width: 164px;
aspect-ratio: 1 / 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid #06262d;
border-radius: 8px;
font-variant-numeric: tabular-nums;
}
/* a different light colour for each box */
.countdown .unit:nth-child(1) { background: #fde2e4; } /* pink */
.countdown .unit:nth-child(2) { background: #e2f0cb; } /* green */
.countdown .unit:nth-child(3) { background: #dbeafe; } /* blue */
.countdown .unit:nth-child(4) { background: #fff1c1; } /* yellow */
.countdown .unit span {
font-size: clamp(1.6rem, 7vw, 4.5rem);
font-weight: 800;
line-height: 1;
margin-bottom: clamp(4px, 1.5vw, 14px);
}
.countdown .unit small {
font-size: clamp(0.65rem, 1.8vw, 0.95rem);
font-weight: 300;
color: #4b5a60;
}
.deadline {
margin-top: 24px;
font-size: 0.95rem;
font-weight: 300;
color: #4b5a60;
text-align: center;
}
.ended { font-size: 1.5rem; font-weight: 800; }
The font-variant-numeric: tabular-nums declaration is worth keeping. Without it, proportional digits have different widths and the whole row shifts slightly every second - a small thing that looks broken once you notice it.
Two other choices keep the layout solid. flex: 1 1 0 with aspect-ratio: 1 / 1 keeps all four cards equal squares on one row at any width, instead of wrapping into a 2x2 grid on phones. And clamp() scales the digits and labels with the viewport, so you do not need a separate media query for every breakpoint. If you are placing the timer inside an existing page, drop the body rule and keep the rest.
Step 3: The update logic
Here is the complete HTML countdown script example, saved as script.js. It runs immediately, then once per second, and stops cleanly at zero. Because it lives in its own file there are no <script> tags inside it - if you would rather paste it straight into your page, wrap it in <script>...</script> just before </body>.
(function () {
var target = new Date('2026-12-31T23:59:59-05:00').getTime();
var box = document.getElementById('countdown');
var el = {
days: document.getElementById('days'),
hours: document.getElementById('hours'),
minutes: document.getElementById('minutes'),
seconds: document.getElementById('seconds')
};
function pad(n) { return String(n).padStart(2, '0'); }
function tick() {
var diff = target - Date.now();
if (diff <= 0) {
box.innerHTML = '<p class="ended">This offer has ended.</p>';
clearInterval(timer);
return;
}
var s = Math.floor(diff / 1000);
el.days.textContent = pad(Math.floor(s / 86400));
el.hours.textContent = pad(Math.floor(s % 86400 / 3600));
el.minutes.textContent = pad(Math.floor(s % 3600 / 60));
el.seconds.textContent = pad(s % 60);
}
var timer = setInterval(tick, 1000);
tick();
})();
Three details in there are deliberate and worth explaining, because they are what separate this from the average snippet.
The interval is assigned before the first tick() call. If you call tick() first and the deadline has already passed, clearInterval receives undefined, does nothing, and the interval then starts anyway. Assigning first means an already-expired timer stops on the very first run.
Every tick recalculates from target. It never decrements a stored counter. This single choice solves the drift problem entirely - more on that below.
The whole thing is wrapped in an IIFE so target, tick and timer never touch the global scope. Drop two of these on one page and they will not fight each other.
Every unit, days included, goes through pad() so all four cards show two digits and look balanced. padStart only adds zeros, so a deadline 100 or more days away still shows the full number.
The date parsing trap
This is the bug that reaches production most often, because it works perfectly on the developer's machine and breaks for everyone in another timezone.
JavaScript parses date strings differently depending on their shape. A date-only string is treated as UTC. A date-and-time string with no offset is treated as the visitor's local time. Same code, same string format family, two completely different deadlines:
// Date-only: parsed as UTC midnight
new Date('2026-12-31')
// -> 2026-12-31T00:00:00.000Z
// Date and time, no offset: parsed as the VISITOR'S local time
new Date('2026-12-31T23:59:59')
// -> a different absolute moment for every timezone
// Explicit offset: one fixed moment worldwide
new Date('2026-12-31T23:59:59-05:00')
// -> 2027-01-01T04:59:59.000Z, everywhere
The consequence of the middle case is that a "sale ends at midnight" timer ends at midnight in each visitor's own timezone - which is a legitimate design if you meant it, and a serious bug if you did not, because your checkout will cut off at one absolute moment while the timer told people something else.
Always include an explicit offset unless you specifically want a per-visitor local deadline. Note that the offset is a fixed number, not a timezone name, so if your deadline falls on the other side of a daylight saving transition you need the offset that applies on that date, not today's.
data-deadline attribute on the container and read it with box.dataset.deadline - then updating a campaign is a markup change rather than a code deploy.Drift, throttling and why this code is immune
setInterval(fn, 1000) does not guarantee a callback every 1000ms. It guarantees at least 1000ms, and the browser will happily make it longer under load. Browsers also aggressively throttle timers in background tabs - a countdown in a tab you left open for an hour may have fired far fewer times than you expect.
This matters enormously for the common implementation that stores a counter and decrements it each tick: after a long campaign, or one trip to a background tab, that counter is simply wrong, and it never recovers.
The code above is immune because it never stores elapsed time. Every tick asks Date.now() what time it is and subtracts. Drift and throttling then affect only when the screen repaints, never what the number says - and the moment the tab is foregrounded again, the next tick shows the correct value. You do not need requestAnimationFrame or a self-correcting scheduler for a per-second display; recalculating from the source is enough.
Edge cases worth handling
A production timer needs a few more decisions that tutorials usually skip.
- Already expired on load. Handled above - the first
tick()writes the ended state and clears the interval immediately. - What zero actually shows. Replacing the cards with a short styled message, as the code above does, is the simplest option. Reloading the page, revealing a different element, or redirecting are all reasonable - but decide deliberately, because an expired timer sitting at all zeros is the default and the worst of the options.
- Invalid dates. A malformed string gives
NaNfromgetTime(), and every comparison againstNaNis false, so the timer renders nothing and fails silently. If the deadline comes from a CMS or an attribute rather than a literal, guard it withisNaN(target)before starting. - The client clock is not trustworthy. All of this reads the visitor's device clock, which they can change. For a display timer that is fine. For anything that gates access or pricing, enforce the deadline on the server - the countdown is presentation, never security.
- Hiding empty units. Showing "00 days" for the last 24 hours is noise. Hiding the days unit when it reaches zero is a nice touch, but keep the remaining layout stable so the row does not jump.
Accessibility
The instinct is to add aria-live="polite" to the countdown so screen reader users hear it. Do not - it will announce a new value every single second and make the page unusable.
The right approach is the plain-text deadline from Step 1. State the date and time in real text next to the timer, so the information is fully available without depending on the animated digits at all. Then let the countdown be the visual enhancement it actually is. This also covers visitors with reduced-motion preferences and anyone whose JavaScript failed to load - the deadline still reaches them.
The same goes for the colour-coded cards: each one carries a text label, so no information depends on colour alone, and the dark digits keep strong contrast on every light background.
Build it or use a tool
Since you are reading this on Rija's site, the honest version of this comparison matters more than the promotional one.
Build your own when the timer is part of an application rather than a campaign, when you need complete control of the markup and the zero-state behaviour, when you want no third-party code on the page at all, or when it has to react to your own state - a cart, a session, a server-supplied deadline. The code above is a complete starting point and you own it entirely.
Use a tool when the timer is marketing collateral. The code is the easy part; what costs time is restyling it for every campaign, letting a non-developer change a date without a deploy, and rebuilding the same component for the fifth landing page. That is the case where Rija saves real effort - you design the countdown, get an embed snippet or a hosted URL, and marketing changes the deadline without touching your repository. The free plan covers 1000+ Google Fonts, custom digit and label colours, gradient backgrounds, digit animations and a custom pixel size; Pro adds image and video backgrounds, logo upload, CTA buttons and analytics.
Neither answer is universally right. A developer who needs one timer inside a product should write the 30 lines. A team shipping a campaign every fortnight should not rewrite them every time. The decision to build countdown timer website components yourself is really a question of how often the deadline changes and who needs to change it.
Where to go next
Want to experiment first? Fork the live demo on CodePen and try changing the deadline, the card colours or the ended message. If you are embedding rather than building, our HTML embed guide covers the script and iframe methods and when each is appropriate. If the timer is going live on a campaign, the design and placement decisions matter more than the implementation - our countdown timer best practices cover those, and measuring countdown timer ROI covers how to tell whether it worked.
How do I make a countdown timer in JavaScript?
Store your deadline as a timestamp, then run a function once per second that subtracts the current time from it and writes the remaining days, hours, minutes and seconds into the page. The important detail is to recalculate from the deadline on every tick rather than decrementing a counter, because that makes the timer self-correcting when the browser throttles or the tab is backgrounded.
Why is my JavaScript countdown showing the wrong time?
Almost always a date parsing issue. A date-only string such as 2026-12-31 is parsed as UTC midnight, while a date and time with no offset such as 2026-12-31T23:59:59 is parsed as the visitor's local time - so the same code produces a different deadline for every timezone. Always include an explicit offset, for example 2026-12-31T23:59:59-05:00, so the deadline is one fixed moment worldwide.
Does setInterval drift and does it matter for a countdown?
Yes, setInterval drifts - the callback is not guaranteed to run exactly every 1000 milliseconds, and browsers throttle timers in background tabs. It does not matter if you recalculate the remaining time from the deadline on each tick, because drift then only affects when the repaint happens, never the number shown. It does matter if you decrement a stored counter, which is why you should not do that.
Should I build my own countdown timer or use a tool?
Build it when you need full control of the markup, want no third-party code on the page, or the timer is part of an application rather than a campaign. Use a tool when the timer is marketing collateral that non-developers need to change, when you want styling and scheduling without a deploy, or when you would otherwise rebuild the same component for every campaign.