A few weeks ago, my friend Simon challenged me to build a clock in JavaScript. I'd built a simple digital clock before but never an analog one. The math intrigued me, so I decided to try it over the weekend. I rewrote the clock from the list of Svelte examples using standard HTML, CSS and JavaScript. I love the idea of using an SVG. I doubt I would have thought of this otherwise!

The HTML

The HTML file is about 4× larger than the JavaScript file for this project! It always makes me happy when there's more HTML than JavaScript; it typically means the web page will be faster and more accessible. The SVG makes up most of the HTML file. I'll show you the HTML in its entirety first, then I'll give an overview of how it works.

<div class="visually-hidden"></div>

<svg viewBox="-50 -50 100 100">
<circle class="clock-face" r="48" />

<line class="major" y1="35" y2="45" transform="rotate(0)" />
<line class="minor" y1="42" y2="45" transform="rotate(6)" />
<line class="minor" y1="42" y2="45" transform="rotate(12)" />
<line class="minor" y1="42" y2="45" transform="rotate(18)" />
<line class="minor" y1="42" y2="45" transform="rotate(24)" />

<line class="major" y1="35" y2="45" transform="rotate(30)" />
<line class="minor" y1="42" y2="45" transform="rotate(36)" />
<line class="minor" y1="42" y2="45" transform="rotate(42)" />
<line class="minor" y1="42" y2="45" transform="rotate(48)" />
<line class="minor" y1="42" y2="45" transform="rotate(54)" />

<line class="major" y1="35" y2="45" transform="rotate(60)" />
<line class="minor" y1="42" y2="45" transform="rotate(66)" />
<line class="minor" y1="42" y2="45" transform="rotate(72)" />
<line class="minor" y1="42" y2="45" transform="rotate(78)" />
<line class="minor" y1="42" y2="45" transform="rotate(84)" />

<line class="major" y1="35" y2="45" transform="rotate(90)" />
<line class="minor" y1="42" y2="45" transform="rotate(96)" />
<line class="minor" y1="42" y2="45" transform="rotate(102)" />
<line class="minor" y1="42" y2="45" transform="rotate(108)" />
<line class="minor" y1="42" y2="45" transform="rotate(114)" />

<line class="major" y1="35" y2="45" transform="rotate(120)" />
<line class="minor" y1="42" y2="45" transform="rotate(126)" />
<line class="minor" y1="42" y2="45" transform="rotate(132)" />
<line class="minor" y1="42" y2="45" transform="rotate(138)" />
<line class="minor" y1="42" y2="45" transform="rotate(144)" />

<line class="major" y1="35" y2="45" transform="rotate(150)" />
<line class="minor" y1="42" y2="45" transform="rotate(156)" />
<line class="minor" y1="42" y2="45" transform="rotate(162)" />
<line class="minor" y1="42" y2="45" transform="rotate(168)" />
<line class="minor" y1="42" y2="45" transform="rotate(174)" />

<line class="major" y1="35" y2="45" transform="rotate(180)" />
<line class="minor" y1="42" y2="45" transform="rotate(186)" />
<line class="minor" y1="42" y2="45" transform="rotate(192)" />
<line class="minor" y1="42" y2="45" transform="rotate(198)" />
<line class="minor" y1="42" y2="45" transform="rotate(204)" />

<line class="major" y1="35" y2="45" transform="rotate(210)" />
<line class="minor" y1="42" y2="45" transform="rotate(216)" />
<line class="minor" y1="42" y2="45" transform="rotate(222)" />
<line class="minor" y1="42" y2="45" transform="rotate(228)" />
<line class="minor" y1="42" y2="45" transform="rotate(234)" />

<line class="major" y1="35" y2="45" transform="rotate(240)" />
<line class="minor" y1="42" y2="45" transform="rotate(246)" />
<line class="minor" y1="42" y2="45" transform="rotate(252)" />
<line class="minor" y1="42" y2="45" transform="rotate(258)" />
<line class="minor" y1="42" y2="45" transform="rotate(264)" />

<line class="major" y1="35" y2="45" transform="rotate(270)" />
<line class="minor" y1="42" y2="45" transform="rotate(276)" />
<line class="minor" y1="42" y2="45" transform="rotate(282)" />
<line class="minor" y1="42" y2="45" transform="rotate(288)" />
<line class="minor" y1="42" y2="45" transform="rotate(294)" />

<line class="major" y1="35" y2="45" transform="rotate(300)" />
<line class="minor" y1="42" y2="45" transform="rotate(306)" />
<line class="minor" y1="42" y2="45" transform="rotate(312)" />
<line class="minor" y1="42" y2="45" transform="rotate(318)" />
<line class="minor" y1="42" y2="45" transform="rotate(324)" />

<line class="major" y1="35" y2="45" transform="rotate(330)" />
<line class="minor" y1="42" y2="45" transform="rotate(336)" />
<line class="minor" y1="42" y2="45" transform="rotate(342)" />
<line class="minor" y1="42" y2="45" transform="rotate(348)" />
<line class="minor" y1="42" y2="45" transform="rotate(354)" />

<line class="hour" y1="2" y2="-20" />

<line class="minute" y1="4" y2="-30" />

<g class="second">
<line class="second-hand" y1="10" y2="-38" />
<line class="second-counterweight" y1="10" y2="2" />
</g>
</svg>

The visually hidden element

On its own, the SVG is inaccessible to people who use a screen reader. It typically gets announced as something like "graphic". This is clearly not very useful, so I added a visually hidden element before the SVG. In my JavaScript file, I'll populate this element with a text version of the current time.

The hour and minute markers

The SVG contains a <circle> element for the clock face. Then there are 12 groups of 5 <line> elements for the hour and minute markers. Finally, there are a few more <line> elements for the hour, minute and second hands.

Note the arguments to the rotate() transform function on the hour and minute markers. There are 360 degrees in a circle and 60 minutes in an hour. This means there should be a minute marker every 6 degrees (360 degrees ÷ 60 minutes = 6 degrees per minute). There are 12 hours on a clock face, meaning there should be an hour marker every 30 degrees (360 degrees ÷ 12 hours = 30 degrees per hour).

Here's how I generated the markup for this. It's essentially a quick and dirty static site generator without all the hassle of setting one up.

  1. I placed a temporary <g> (group) element inside the <svg> element. This is a bit like a <div> element for SVG.
  2. Starting with an empty string, I looped through the numbers from 0 to 360, counting in increments of 6. If the current number was divisible by 5, I appended an hour marker to the string. Otherwise, I appended a minute marker.
  3. I inserted the HTML string into the <g> element. Then I opened my browser's developer tools, copied the HTML and pasted it into my HTML file. Finally, I removed the temporary <g> element and JavaScript code.
/**
* Temporary code to generate the hour and minute markers.
*/


let temp = document.querySelector("g");
let htmlString = "";

for (let i = 0; i < 360; i += 6) {
if (i % 5 == 0) {
htmlString += `<line class="major" y1="35" y2="45" transform="rotate(${i})">`;
} else {
htmlString += `<line class="minor" y1="42" y2="45" transform="rotate(${i})">`;
}
}

temp.innerHTML = htmlString;

The CSS

The CSS is actually very simple. The SVG does all the hard work to create the shapes; the CSS just changes the color and thickness of the lines. The only thing I did differently from the Svelte example was add the .visually-hidden class and style the <body> and <svg> elements to center the clock on the page.

body {
display: flex;
height: 100vh;
margin: 0;
justify-content: center;
align-items: center;
}

svg {
width: 50vw;
height: 100%;
}

.clock-face {
stroke: #333;
fill: white;
}

.minor {
stroke: #999;
stroke-width: 0.5;
}

.major {
stroke: #333;
stroke-width: 1;
}

.hour {
stroke: #333;
}

.minute {
stroke: #666;
}

.second-hand,
.second-counterweight
{
stroke: rgb(180, 0, 0);
}

.second-counterweight {
stroke-width: 3;
}

/* https://www.a11yproject.com/posts/how-to-hide-content/ */
.visually-hidden {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}

The JavaScript

At the top of my script, I declare constants for the number of degrees per hour, minute and second. Although the DEGREES_PER_MINUTE and DEGREES_PER_SECOND constants have the same value, I think this is simpler than declaring a constant like DEGREES_PER_MINUTE_OR_SECOND. As Harry Roberts says, DRY is often misinterpreted as the necessity to never repeat the exact same thing twice. This is impractical and usually counterproductive, and can lead to forced abstractions, over-thought and over-engineered code.

const DEGREES_PER_HOUR = 360 / 12;
const DEGREES_PER_MINUTE = 360 / 60;
const DEGREES_PER_SECOND = 360 / 60;

After declaring my constants, I declare variables for the visually hidden element and the clock hands.

let visuallyHidden = document.querySelector(".visually-hidden");

let hourHand = document.querySelector(".hour");
let minuteHand = document.querySelector(".minute");
let secondHand = document.querySelector(".second");

Then I declare a function called tick() with three parameters: h, m and s. When I invoke this function, I'll pass in the return values of the Date.prototype.getHours(), Date.prototype.getMinutes() and Date.prototype.getSeconds() methods.

function tick(h, m, s) {}

The screen reader experience

The .getHours() method returns a number from 0 to 23 in 24-hour time. Because analog clocks only deal with 12 hours, I do the conversion. If the value of h is equal to 0, I change it to 12. Otherwise, I change it to the remainder after dividing by 12. While the conversion is unnecessary for the visual display, it is necessary for the screen reader announcement. I don't want screen readers to read the time in 24-hour format.

function tick(h, m, s) {
h = h == 0 ? 12 : h % 12;
}

I want the text content of the visually hidden element to be in hh:mm:ss format. Most screen readers will read this as a timestamp. I declare three variables: hh, mm and ss. I assign them the values of the h, m and s variables but padded with leading zeroes. I use the String.prototype.padStart() method to accomplish this. Then I update the value of the visually hidden element's .textContent property.

function tick(h, m, s) {
h = h == 0 ? 12 : h % 12;

let hh = h.toString().padStart(2, "0");
let mm = m.toString().padStart(2, "0");
let ss = s.toString().padStart(2, "0");

visuallyHidden.textContent = `${hh}:${mm}:${ss}`;
}

I have tested this in a few different screen readers:

  • VoiceOver in Safari 16.1 on macOS Monterey 12.6.1
  • VoiceOver in Safari 16.1 on iOS 16.1.1
  • Narrator in Edge 107 on Windows 10 21H2
  • TalkBack in Chrome 107 on Android 13

VoiceOver and TalkBack read the time consistently. For example, they read it as "6 hours, 42 minutes and 14 seconds". Narrator reads this as "6:42 and 14 seconds", but I think it still makes sense.

Moving the hour hand

To work out the position of the hour hand, I first calculate DEGREES_PER_HOUR * h. This is the number of degrees per hour (30) × the current hour (0 to 23). For example, if the current hour is 6, the result will be 30 degrees × 6 = 180 degrees. This takes the hour hand to 6 o'clock. It works in 24-hour time too. This is because 30 degrees × 18 = 540 degrees and 540 degrees ÷ 360 = 1.5 turns. Therefore, the hour hand still finishes at 6 o'clock.

To work out how far the hour hand should sit between the current hour marker and the next hour marker, I calculate m / 2. This is the current minute (0 to 59) ÷ 2. To keep the math simple, imagine the current minute is 30, i.e. halfway through the current hour. We know there are 30 degrees between each hour marker. 30 ÷ 2 = 15, meaning we should add an extra 15 degrees to the position of the hour marker. This places it halfway between the current hour marker and the next hour marker.

function tick(h, m, s) {
// . . .

let hourPosition = DEGREES_PER_HOUR * h + m / 2;
hourHand.setAttribute("transform", `rotate(${hourPosition})`);
}

Moving the minute hand

To work out the position of the minute hand, I first calculate DEGREES_PER_MINUTE * m. This is the number of degrees per minute (6) × the current minute (0 to 59). For example, if the current minute is 10, the result will be 6 degrees × 10 = 60 degrees. This takes the minute hand to 2 o'clock, i.e. 10 minutes past the hour.

To work out how far the minute hand should sit between the current minute marker and the next minute marker, I calculate s / 10. This is the current second (0 to 59) ÷ 10. To keep the math simple, imagine the current second is 30, i.e. halfway through the current minute. We know there are 6 degrees between each minute marker. 30 ÷ 10 = 3, meaning we should add an extra 3 degrees to the position of the minute marker. This places it halfway between the current minute marker and the next minute marker.

function tick(h, m, s) {
// . . .

let minutePosition = DEGREES_PER_MINUTE * m + s / 10;
minuteHand.setAttribute("transform", `rotate(${minutePosition})`);
}

Moving the second hand

To work out the position of the second hand, I calculate DEGREES_PER_SECOND * s. This is the number of degrees per second (6) × the current second (0 to 59). For example, if the current second is 45, the result will be 6 degrees × 45 = 270 degrees. This takes the second hand to 9 o'clock, i.e. 45 seconds past the minute.

function tick(h, m, s) {
// . . .

let secondPosition = DEGREES_PER_SECOND * s;
secondHand.setAttribute("transform", `rotate(${secondPosition})`);
}

Making the clock tick

To make the clock start ticking, I declare a variable called time and assign it a Date object. Then I declare variables called hours, minutes and seconds, assigning them the return values of the .getHours(), .getMinutes() and .getSeconds() methods. Finally, I invoke the tick() function with these values.

let time = new Date();

let hours = time.getHours();
let minutes = time.getMinutes();
let seconds = time.getSeconds();

tick(hours, minutes, seconds);

To make the clock tick every second, I pass an anonymous callback function to the window.setInterval() method. I reassign the time, hours, minutes and seconds variables and invoke the tick() function with the new values.

setInterval(function () {
time = new Date();

hours = time.getHours();
minutes = time.getMinutes();
seconds = time.getSeconds();

tick(hours, minutes, seconds);
}, 1000);

Summary

This was a really fun challenge! I honestly had no idea how to approach the visual layout of the clock, so I'm glad I was able to deconstruct the Svelte example. I never fully appreciated how powerful SVGs are, nor how easy it is to create simple ones from scratch. The most fun part for me was the math, however—particularly working out the extra offsets for the positions of the clock hands.