Skip to main content

Project 09 · Intermediate

Countdown Timer

A countdown to New Year's Day that you can point at any date and time. Every second it works out how long is left and splits it into days, hours, minutes, and seconds. It's mostly maths with milliseconds, plus a timer.

  • 3 HTML ideas
  • 2 CSS ideas
  • 7 JavaScript ideas

Why build it

Working with dates in milliseconds lets you build countdowns for launches, sales, and weddings, "posted 3 hours ago" labels, days-until and age calculators, and deadline reminders. Dates trip up most developers, from months that start at 0 to time zones, and the habit you learn here keeps them manageable.

Skills you'll earn

  • Create, read, and compare dates with the Date object
  • Turn a number of milliseconds into days, hours, minutes, and seconds
  • Always show two digits, like 09, with padStart
  • Start a timer and stop it safely, so two never run at once

01

Try it

Play with it first, then read the code that makes it work.

Result0px
New tab (opens in a new tab)
Download
<!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>
		<main class="countdown">
			<h1 class="countdown-title">Countdown</h1>

			<!-- datetime-local gives a date and time picker, no library needed. -->
			<label class="target-label" for="target">Counting down to</label>
			<input class="target-input" type="datetime-local" id="target" />

			<!-- role="timer" is quiet by default, so it isn't read out every second. -->
			<div class="units" role="timer" aria-label="Time left">
				<div class="unit">
					<span class="unit-value" id="days">0</span>
					<span class="unit-label">days</span>
				</div>
				<div class="unit">
					<span class="unit-value" id="hours">00</span>
					<span class="unit-label">hours</span>
				</div>
				<div class="unit">
					<span class="unit-value" id="minutes">00</span>
					<span class="unit-label">minutes</span>
				</div>
				<div class="unit">
					<span class="unit-value" id="seconds">00</span>
					<span class="unit-label">seconds</span>
				</div>
			</div>

			<!-- Only the important moments are announced, like reaching zero. -->
			<p class="status" id="status" aria-live="polite"></p>
		</main>

		<script src="script.js"></script>
	</body>
</html>

02

How it works

The ideas behind it, one at a time, each next to the code that does it.

HTMLthe structure

  1. 01

    Pick a date and time with datetime-local

    An <input>'s type decides what kind of value it takes. type="datetime-local" gives a built-in date and time picker that works with a mouse, a keyboard, and on phones, with no extra code or library. The <label>'s for="target" matches the input's id, which names the input for screen readers and lets a click on the label focus it.

    			<!-- datetime-local gives a date and time picker, no library needed. -->
    			<label class="target-label" for="target">Counting down to</label>
    			<input class="target-input" type="datetime-local" id="target" />
  2. 02

    Keep it quiet with role="timer"

    A role tells screen readers what kind of thing an element is. role="timer" says this area counts time and changes on its own. Timers are quiet by default: screen readers don't announce each change, which here would mean reading a new number every second. People can still go to it and hear the time left, named by aria-label="Time left".

    			<!-- role="timer" is quiet by default, so it isn't read out every second. -->
    			<div class="units" role="timer" aria-label="Time left">
  3. 03

    Announce only what matters

    Because the timer itself is quiet, there needs to be another way to hear the moments that matter. The status paragraph is an aria-live region, so screen readers read out any text put into it. It stays empty most of the time, and JavaScript only fills it for something worth hearing: "Time's up!" or "Pick a time in the future."

    			<!-- Only the important moments are announced, like reaching zero. -->
    			<p class="status" id="status" aria-live="polite"></p>

CSSthe look

  1. 04

    Wrap boxes with auto-fit and minmax()

    grid-template-columns sets the grid's columns. minmax(5.5rem, 1fr) means each column is at least 5.5rem wide and grows to an equal share of any spare space. repeat(auto-fit, ...) makes as many of those columns as fit in the row. So the four boxes sit in one row on a wide screen and wrap onto two rows on a narrow one, without a media query.

    /* Four equal boxes that wrap into two rows on narrow screens. */
    .units {
    	display: grid;
    	grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr));
    	gap: 0.75rem;
    	margin-top: 2rem;
    }
  2. 05

    Stop the numbers jiggling

    In most fonts, digits have different widths: a 1 is narrower than an 8. When the seconds change from 11 to 18, the text shifts sideways, and with four boxes updating every second the whole timer would jiggle. font-variant-numeric: tabular-nums gives every digit the same width, so the numbers stay put.

    /* Every digit the same width, so the numbers don't jiggle as they change. */
    .unit-value {
    	display: block;
    	font-size: 2.25rem;
    	font-weight: 700;
    	font-variant-numeric: tabular-nums;
    }

JavaScriptthe behaviour

  1. 06

    Name each unit in milliseconds

    JavaScript measures time in milliseconds: one second is 1000. The code builds each unit from the one before, so MINUTE is 60 * SECOND, HOUR is 60 * MINUTE, and DAY is 24 * HOUR. Naming them once makes the maths below read like words, like msLeft / DAY, instead of numbers like 86400000. They're const because they never change.

    // Dates are counted in milliseconds, so name each unit once.
    const SECOND = 1000;
    const MINUTE = 60 * SECOND;
    const HOUR = 60 * MINUTE;
    const DAY = 24 * HOUR;
  2. 07

    Always show two digits with padStart

    padStart() is a string method that adds characters to the front until the text reaches a set length. pad() first turns the number into text with String(), then padStart(2, "0") adds zeros until it's two characters long, so 7 seconds shows as 07. Text that's already two characters, like 42, is left alone.

    // Two digits always: 7 becomes "07".
    function pad(number) {
    	return String(number).padStart(2, "0");
    }
  3. 08

    Turn a date into input text

    A datetime-local input's value must be text in one exact format, like 2027-01-01T00:00. A Date object has get methods that return each part as a number: getFullYear(), getMonth(), getDate(), getHours() and getMinutes(). toInputValue() pads each part to two digits and joins them. getMonth() counts from 0, so January is 0 and the code adds 1.

    // datetime-local wants text like "2027-01-01T00:00", in local time.
    function toInputValue(date) {
    	const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
    	const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
    	return `${day}T${time}`;
    }
  4. 09

    Split milliseconds into days, hours, minutes, and seconds

    The time left is one big number of milliseconds, and the page needs it in four units. Dividing by DAY and rounding down with Math.floor() gives the whole days. % gives the remainder after dividing, so msLeft % DAY is what's left over that doesn't make a whole day; dividing that by HOUR gives the hours. Minutes and seconds follow the same pattern.

    function showTimeLeft(msLeft) {
    	// Divide to get whole units; % keeps only what's left under the next unit.
    	daysDisplay.textContent = Math.floor(msLeft / DAY);
    	hoursDisplay.textContent = pad(Math.floor((msLeft % DAY) / HOUR));
    	minutesDisplay.textContent = pad(Math.floor((msLeft % HOUR) / MINUTE));
    	secondsDisplay.textContent = pad(Math.floor((msLeft % MINUTE) / SECOND));
    }
  5. 10

    Find the time left by subtracting

    To find the time left, you need both dates as plain numbers. new Date(targetInput.value) turns the input's text into a Date, and getTime() gives that moment in milliseconds. Date.now() gives the current moment in milliseconds. Subtracting now from the target gives the milliseconds left; if the result is zero or less, the time has passed.

    // How long until the picked date. getTime() turns a date into milliseconds.
    function timeLeft() {
    	const target = new Date(targetInput.value);
    	return target.getTime() - Date.now();
    }
  6. 11

    Start and stop the timer

    setInterval(tick, SECOND) calls tick() every second and returns an id, kept in timer. clearInterval(timer) uses that id to stop it. tick() stops the timer once time runs out. start() also clears it first, before checking the new date, so picking another date never leaves two timers running. start() calls tick() once straight away, so you don't wait a second for the first update.

    function tick() {
    	const msLeft = timeLeft();
    	if (msLeft <= 0) {
    		// Stop the timer once it reaches zero, or it would keep running.
    		clearInterval(timer);
    		showTimeLeft(0);
    		statusMessage.textContent = "Time's up!";
    		return;
    	}
    	showTimeLeft(msLeft);
    }
    
    function start() {
    	clearInterval(timer);
    	statusMessage.textContent = "";
    	if (!targetInput.value) return;
    
    	if (timeLeft() <= 0) {
    		showTimeLeft(0);
    		statusMessage.textContent = "Pick a time in the future.";
    		return;
    	}
    
    	tick();
    	// setInterval runs tick again every second until clearInterval stops it.
    	timer = setInterval(tick, SECOND);
  7. 12

    Make a date with new Date()

    new Date(year, month, day) builds a Date for a particular day. new Date().getFullYear() is this year, so adding 1 gives next year; month 0 is January, and day 1 is the first. That's next New Year's Day. The code puts it in the input with toInputValue() and calls start(), so the countdown is running as soon as the page opens.

    // Start by counting down to next New Year's Day. Month 0 is January.
    const nextNewYear = new Date(new Date().getFullYear() + 1, 0, 1);
    targetInput.value = toInputValue(nextNewYear);
    start();

03

Wrap up

Track your progress, then go deeper or keep going.

Take it further

Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.

  1. Challenge 01

    Let people name the countdown, like Birthday, and show the name in the heading.

  2. Challenge 02

    Save the chosen date in localStorage.

  3. Challenge 03

    Show a progress bar of how much of the wait has passed.

Articles that go further into ideas from this project.

Finished Countdown Timer?

Mark it done to track your progress.

You finished Countdown Timer!

0 of 15 projects done.

Go deeper

The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.

Article coming soon. See the series so far (opens in a new tab)