Making an SVG clock with Javascript & React
We're actually going to make two clocks.
The first one will be focused on being (and staying) correct and exploring react's useRef
to manipulate html nodes, with the benefit of avoiding triggering rerenders (which is nice when you have a component that changes state every single second, or even every millisecond if you go down that road).
The second one will build on those concepts to lean forward and explore how one might want interact with SVGs while using react. It involves a touch of trigonometry, but nothing fancy and I'll explain every step along the way.
Along the way, we'll think a bit about hooks such as useEffect & useRef, and how we can use setInterval
in react.
Getting the actual time
First we'd like to get the actual time as a string so we can render it inside JSX.
Fortunately, there are very handy browser methods implemented on the Date
prototype in JavaScript to do so, that are also widely supported accross browsers.
function getTime(time = new Date()) {
let hours, minutes, seconds;
hours = time.getHours();
minutes = time.getMinutes();
seconds = time.getSeconds();
return { hours, minutes, seconds };
}
console.log(getTime()); // {hours: "21", minutes:"34", seconds:"43"}
Note that we're providing a default value for our getTime
function: That way the default behavior is to always print out now's time, while if you want to reuse it to format some other Date
object later, you still can.
Now we'd probably like to display it somewhere, so let's write some JSX:
function Clock() {
const { hours, minutes, seconds } = getTime();
return <time>{`${hours}:${minutes}:${seconds}`}</time>;
}
Manipulating state
The obvious problem now is updating our component's state. While talking about state might make you reach out for the good old useState
hook, think twice: every state update will trigger a rerender in our component.
The problem is, if you rely on setInterval
to recompute the time, you're clock will end up out of sync since for each second you add an undeterminate amount of time will add up to that, which is the time the rerender took. After 20-25 minutes you'll end up with a almost a minute of difference, which is quite unacceptable.
Fortunately, other JavaScript methods for updating html nodes are still available to us if we know how to use them: This is the purpose of useRef
.
Targetting html nodes with useRef
We need to pass on a ref to our <time>
node so we can target it and manipulate its contents directly, outside the control of react:
import { useEffect, useRef } from "react";
function Clock() {
const clockRef = useRef();
const { hours, minutes, seconds } = getTime();
return <time ref={clockRef}>{`${hours}:${minutes}:${seconds}`}</time>;
}
We now have a way to target our <time>
node just as if we had used document.getElementById()
or any other JavaScript method for targetting html nodes. The node will be accessible once the JSX is mounted using the following syntax:
const node = clockRef.current;
Using setInterval in react
If you try using setInterval
in the code preceding the return statement in a component, you're in for a disappointment: Every time react's reconciliation algorithm will visit the component, the setInterval
function will start anew, making it absolutely unpredictable.
For this reason, we need to use it inside a useEffect
statement, written with an empty dependency array, so that the function will start exactly once and go on as long as the component is mounted.
Incidentally, since we know the node will be accessible once the html is mounted, we can then refer to it inside this statement, allowing us to manipulate the node ref.
Let's see it in action:
export default function Clock() {
const clockRef = useRef();
const { hours, minutes, seconds } = getTime();
useEffect(() => {
const interval = setInterval(() => {
const { hours, minutes, seconds } = getTime();
clockRef.current.innerHTML = `${hours}:${minutes}:${seconds}`;
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<time ref={clockRef} className="border border-black text-center w-32 py-1">
{`${hours}:${minutes}:${seconds}`}
</time>
);
}
We now update the dom node we have a ref to every 1000ms, updating it with the current time instead of adding up 1000ms to the initial time by hand.
That way, the clock will still be accurate and there will be no mismatch between the user's operating system and the time displayed on our clock.
Note that we return a function from the useEffect hook: This function will be called when the component is unmounted. This is where we should clear the timeout, which is important in order to prevent memory leaks.
Formatting the time
But we now notice a flaw with our implementation: When any of the seconds, minutes or hours are lower than 10, we get the number of seconds without prepended zeros as is usually the case with digital clocks.
To fix that, let's create both a prependZero
and a formatTime
functions to make sure there's a leading 0 if the number is less than 10, like so:
function prependZero(num) {
return num > 9 ? num : "0" + num;
}
function formatTime({ hours, minutes, seconds }) {
return {
hours: prependZero(hours),
minutes: prependZero(minutes),
seconds: prependZero(seconds),
};
}
function getTime(time = new Date()) {
let hours, minutes, seconds;
hours = time.getHours();
minutes = time.getMinutes();
seconds = time.getSeconds();
return formatTime({ hours, minutes, seconds });
}
And here's our clock, ready to tick!
You might notice the time is moving left and right in your version if you didn't use a monospace font. This is because the string length keeps changing as different characters take up different space, whereas with monospace fonts each character takes up exactly the same space, hence the name.
To fix it, you can either:
- Change the font for a monospace one
- Use three refs instead of one and some css to make sure everything stays in place.
Bonus: Using Intl.DateTimeFormat and localize time formatting
But there is an even better way to generate a time string: with the Intl.DateTimeFormat object that provides us with a powerful abstraction to format and localize date, time or both.
For instance, we can rewrite our getTime
function like so:
function getTime(date = new Date()) {
return new Intl.DateTimeFormat("en-GB", {
hour: "numeric",
minute: "numeric",
second: "numeric",
}).format(date);
}
This yields exactly the same result, the difference being you can localize time formatting and add / remove components. You could for instance make a clock displaying milliseconds by adding fractionalSecondDigits = 3
to the Intl.DateTimeFormat options object (but don't forget to adapt the setInterval
function's interval accordingly).
Which leaves us with the following code:
import { useEffect, useRef } from "react";
function getTime(time = new Date()) {
return new Intl.DateTimeFormat("en-GB", {
hour: "numeric",
minute: "numeric",
second: "numeric",
}).format(time);
}
export default function DigitalClock() {
const clockRef = useRef();
useEffect(() => {
const interval = setInterval(() => {
clockRef.current.innerHTML = getTime();
}, 1000);
return () => clearInterval(interval);
}, []);
return <time ref={clockRef}>{getTime()}</time>;
}
Be mindful that the support for the Intl.DateTimeFormat object is not as widespread as for the previously explored method, so you might have to use a polyfill depending on your use case.
SVGs + JSX = <3
A quick look at the MDN docs shows us that Scalable Vector Graphics (SVG) are an XML-based markup language for describing two-dimensional based vector graphics.
Which mean we can write them using the familiar syntax, and, as we'll see, interact with them while using react just like with any other node!
A quick note: I'm not using react because it makes it easier working with SVGs, bug because this is the most widespread front-end library as of now. It's likely the case that you have to work within constraints, just as I do. But worry not: Working with SVGs while using react involves very little overhead. You've just got to stay mindful of rerenders.
Very intuitively, to make an SVG you use the <svg>
tag:
export default function AnalogClock() {
return <svg viewBox="0 0 100 100"></svg>;
}
To see what space your SVG is taking, just give it a backgroundColor
using inline styles.
The viewBox
appropriately describes the box the SVG will give you to work with and it's coordinate system.
The convention is to use "0 0"
to describe the coordinates of the upper-left corner. I put a 100 here as it is easier to reason about and gives us enough precision for our purpose.
Once you'll define a backgroundColor
, you'll notice the SVG takes up all the space it can either vertically or horizontally. To solve that, just put it inside a <div>
with a width and height. The svg will now only take the maximum space within its parent.
export default function AnalogClock() {
return (
<div style={{ height: 300, width: 300 }}>
<svg viewBox="0 0 100 100" style={{ backgroundColor: "goldenrod" }}></svg>
</div>
);
}
We can now start "drawing" our SVG.
We need three hands for our clock, for the hours, minutes and seconds. That's three line, which we'll draw like follow, based on our coordinate system:
export default function AnalogClock() {
return (
<div style={{ height: 300, width: 300 }}>
<svg viewBox="0 0 100 100" style={{ backgroundColor: "goldenrod" }}>
<circle cx="50" cy="50" r="50" fill="goldenrod" />
<g stroke="#000">
{/* hours */}
<line x1="50" y1="25" x2="50" y2="50" strokeWidth="1" />
{/* minutes */}
<line x1="50" y1="15" x2="50" y2="50" strokeWidth=".5" />
{/* seconds */}
<line x1="50" y1="5" x2="50" y2="50" strokeWidth=".1" />
</g>
{/* A circle like on real clocks */}
<circle cx={p} cy={q} r="1" fill="#000" />
</svg>
</div>
);
}
The first element is a nice big yellow circle which will serve as the clock's quadrant. It's important it stays first, since SVG elements are drawn in order. If it was last, it would be on top of everything else and we couldn't see the time anymore...
You'll notice the <g>
tag, which allows us to group SVG elements and to avoid repeating properties such as their stroke
property.
We've also added a circle right above the pivot point to mimic real clocks and make things look smoother.
Now how are we supposed to make things move?
Targetting SVGs with useRef
The answer is in the title, nothing more! So let's update our component to use refs, which you now know everything about:
export default function AnalogClock() {
const secondsRef = useRef();
const minutesRef = useRef();
const hoursRef = useRef();
return (
<div style={{ height: 300, width: 300 }}>
<svg viewBox="0 0 100 100" style={{ backgroundColor: "goldenrod" }}>
<circle cx="50" cy="50" r="50" fill="goldenrod" />
<g stroke="#000">
<line
ref={hoursRef}
x1="50"
y1="25"
x2="50"
y2="50"
strokeWidth="1"
/>
<line
ref={minutesRef}
x1="50"
y1="15"
x2="50"
y2="50"
strokeWidth=".5"
/>
<line
ref={secondsRef}
x1="50"
y1="5"
x2="50"
y2="50"
strokeWidth=".1"
/>
</g>
<circle cx="50" cy="50" r="1" fill="#000" />
</svg>
</div>
);
}
Things are still static, but we can now target our hands to update them.
But first...
Some trigonometry
Since a well defined problem is almost indistinguishable from a solution, let's try to define hours:
We need to update each hand's topmost coordinates rotating them according to the clock's center.
Let's try to first answer the question: By which amount should we rotate those points? Our clock is a circle and there are 360° in a circle.
So the question of the rotation amount (aka theta
) is actually the question of what proportion of those 360° do we need to rotate a specific hand by.
It's essentially a simple division: const theta = 360 / needed_amount_of_steps
.
Which gives us a 6° rotation for minutes and seconds, and a 30° rotation per hour.
Now that we've answered this question, let's remember the formula to rotate a point (x,y) in a 2d coordinate system according to a point (p,q).
Kudos to Zev for answering my question ahead of time, thus not forcing me to open a high school textbook:
There is a last adjustment to be made: Since Math.cos
and Math.sin
accepts an angle in radians, we need to convert our degree-based steps in radians first.
The formula is simple enough:
const rad_theta = (theta * Math.PI) / 180;
To be honest, it took me a while to debug the clock as I first tried with degrees. But I'm glad I could spare you a headache!
Now we've got all the math we need, we have yet to update the x1
and y1
properties of each line representing a hand.
Making the clock tick
Let's start with the seconds. We've already explored how to use setInterval
while working with react, and we have a getTime
function ready to go, so let's work from there:
const p = 50;
const q = 50;
export default function AnalogClock() {
const secondsRef = useRef();
const minutesRef = useRef();
const hoursRef = useRef();
useEffect(() => {
const interval = setInterval(() => {
const { hours, minutes, seconds } = getTime();
// 6 is the number of degrees needed per step as calculated earlier
const seconds_rad_theta = (6 * seconds * Math.PI) / 180;
const seconds_x1 =
(50 - p) * Math.cos(seconds_rad_theta) -
(5 - q) * Math.sin(seconds_rad_theta) +
p;
const seconds_y1 =
(50 - p) * Math.sin(seconds_rad_theta) +
(5 - q) * Math.cos(seconds_rad_theta) +
q;
secondsRef.current.setAttributeNS(null, "x1", seconds_x1);
secondsRef.current.setAttributeNS(null, "y1", seconds_y1);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div style={{ height: 300, width: 300 }}>
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="50" fill="goldenrod" />
<g stroke="#000">
<line
ref={hoursRef}
x1="50"
y1="25"
x2="50"
y2="50"
strokeWidth="1"
/>
<line
ref={minutesRef}
x1="50"
y1="15"
x2="50"
y2="50"
strokeWidth=".5"
/>
<line
ref={secondsRef}
x1="50"
y1="5"
x2={p}
y2={q}
strokeWidth=".1"
/>
</g>
<circle cx={p} cy={q} r="1" fill="#000" />
</svg>
</div>
);
}
Hooray! It ticks, and it seems to be correct!
Note we've used setAttributeNS
: This function slightly differs from the setAttribute
method because the element we're targetting are not HTML elements but are in the SVG namespace. To use it you need to pass it an extra first argument, which can be null in our case.
You can now guess how we'll proceed to udpate the hours and minutes hands. I'll include the completed code below, but first let's focus on another problem: as we see, when the component first mounts, it's not on a time. This is because we gave it a fixed value, which only gets replaced after a second or so.
To fix that, let's refactor a bit so we can be on time right from the start: We'll make a getHandsPositions
that we can reuse both before the component mounts and in our setInterval
call.
const p = 50;
const q = 50;
function getTime(time = new Date()) {
let hours, minutes, seconds;
hours = time.getHours();
minutes = time.getMinutes();
seconds = time.getSeconds();
return { hours, minutes, seconds };
}
function getHandsPositions() {
const { hours, minutes, seconds } = getTime();
const seconds_theta = (6 * seconds * Math.PI) / 180;
const seconds_x1 =
(50 - p) * Math.cos(seconds_theta) - (5 - q) * Math.sin(seconds_theta) + p;
const seconds_y1 =
(50 - p) * Math.sin(seconds_theta) + (5 - q) * Math.cos(seconds_theta) + q;
return {
seconds_x1,
seconds_y1,
};
}
export default function AnalogClock() {
const secondsRef = useRef();
const minutesRef = useRef();
const hoursRef = useRef();
const { seconds_x1, seconds_y1 } = getHandsPositions();
useEffect(() => {
const interval = setInterval(() => {
const { seconds_x1, seconds_y1 } = getHandsPositions();
secondsRef.current.setAttributeNS(null, "x1", seconds_x1);
secondsRef.current.setAttributeNS(null, "y1", seconds_y1);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div style={{ height: 300, width: 300 }}>
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="50" fill="goldenrod" />
<g stroke="#000">
<line
ref={hoursRef}
x1="50"
y1="25"
x2="50"
y2="50"
strokeWidth="1"
/>
<line
ref={minutesRef}
x1="50"
y1="15"
x2="50"
y2="50"
strokeWidth=".5"
/>
<line
ref={secondsRef}
x1={seconds_x1}
y1={seconds_y1}
x2={p}
y2={q}
strokeWidth=".1"
/>
</g>
<circle cx={p} cy={q} r="1" fill="#000" />
</svg>
</div>
);
}
Indeed, the code before the return statement gets executed before the HTML is mounted in the DOM. Therefore, it'll be on time right from the start (only for the seconds for now though).
And here's the complete code that makes all hands tick:
import { useEffect, useRef } from "react";
const p = 50;
const q = 50;
function getTime(time = new Date()) {
let hours, minutes, seconds;
hours = time.getHours();
minutes = time.getMinutes();
seconds = time.getSeconds();
return { hours, minutes, seconds };
}
function getHandsPositions() {
const { hours, minutes, seconds } = getTime();
const seconds_theta = ((360 / 60) * seconds * Math.PI) / 180;
const minutes_theta = ((360 / 60) * minutes * Math.PI) / 180;
const hours_theta = ((360 / 12) * hours * Math.PI) / 180;
const seconds_x1 =
(50 - p) * Math.cos(seconds_theta) - (5 - q) * Math.sin(seconds_theta) + p;
const seconds_y1 =
(50 - p) * Math.sin(seconds_theta) + (5 - q) * Math.cos(seconds_theta) + q;
const minutes_x1 =
(50 - p) * Math.cos(minutes_theta) - (15 - q) * Math.sin(minutes_theta) + p;
const minutes_y1 =
(50 - p) * Math.sin(minutes_theta) + (15 - q) * Math.cos(minutes_theta) + q;
const hours_x1 =
(50 - p) * Math.cos(hours_theta) - (25 - q) * Math.sin(hours_theta) + p;
const hours_y1 =
(50 - p) * Math.sin(hours_theta) + (25 - q) * Math.cos(hours_theta) + q;
return {
seconds_x1,
seconds_y1,
minutes_x1,
minutes_y1,
hours_x1,
hours_y1,
};
}
export default function AnalogClock() {
const secondsRef = useRef();
const minutesRef = useRef();
const hoursRef = useRef();
const { seconds_x1, seconds_y1, minutes_x1, minutes_y1, hours_x1, hours_y1 } =
getHandsPositions();
useEffect(() => {
const interval = setInterval(() => {
const {
seconds_x1,
seconds_y1,
minutes_x1,
minutes_y1,
hours_x1,
hours_y1,
} = getHandsPositions();
secondsRef.current.setAttributeNS(null, "x1", seconds_x1);
secondsRef.current.setAttributeNS(null, "y1", seconds_y1);
minutesRef.current.setAttributeNS(null, "x1", minutes_x1);
minutesRef.current.setAttributeNS(null, "y1", minutes_y1);
hoursRef.current.setAttributeNS(null, "x1", hours_x1);
hoursRef.current.setAttributeNS(null, "y1", hours_y1);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div style={{ height: 300, width: 300 }}>
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="50" fill="goldenrod" />
<g stroke="#000">
<line
ref={hoursRef}
x1={hours_x1}
y1={hours_y1}
x2={p}
y2={q}
strokeWidth="1"
/>
<line
ref={minutesRef}
x1={minutes_x1}
y1={minutes_y1}
x2={p}
y2={q}
strokeWidth=".5"
/>
<line
ref={secondsRef}
x1={seconds_x1}
y1={seconds_y1}
x2={p}
y2={q}
strokeWidth=".1"
/>
</g>
<circle cx={p} cy={q} r="1" fill="#000" />
</svg>
</div>
);
}
And what can I say? It does tick, and it is correct.
But before congratulating ourselves, let's check that non of what we do triggers rerenders, which is the whole point of using ref.
To do so, just add this at the top of our component:
useEffect(() => console.log("render"));
Omitting the dependency Array in a useEffect
call results in it running on every render.
This is a the fancy way, and, to be honest, you could have just written:
console.log("render");
But this is the way I learned and what can I say, habits stick.
In any case, we see only one log stating "render", meaning that our component does render only one time!
Notes on SSR
As you may have noticed, the clock isn't on time when it is first mounted onto the DOM. This is because this page is prerendered using SSR (Static Site Generation), so it will start at the time the site was prerendered.
Unfortunately this creates an ugly 'time shift' in our clock. To tackle this problem, two solutions:
- Delay rendering for the clock until it's mounted onto the user's DOM.
- Come up with some animation (think spinning the clock hands) when it first mounts which ends up on the current time.
I'll go with the former, which also happens to illustrate how you can delay rendering in your react applications.
For a component to return nothing, it needs to return null
. And to test if we're in the browser or not, we can use a simple condition typeof window !== 'undefined'
.
Knowing that, delaying rendering of any component is as simple as writing the following:
import SomeComponent from "./SomeComponent";
export default function OtherComponent() {
if (typeof window === "undefined") return null;
return <SomeComponent />;
}
But now the next problem you'll need to tackle is layout shift, which is highly undesirable. Since our component returns nothing, it doesn't take the space in the document it will need when mounted. Therefore you'll see a glitch when it first mounts, as it "pushes" content below it.
Aside from being bad for SEO, it looks ugly. What can you do to mitigate that? Make sure the component takes all the space it needs in advance by wrapping it up in a <div>
which height
is set to a fixed value which does not depend on its contents.
Challenge time !
I'll leave you with a few ideas to make use of what we've covered:
- You now have all the tools to make the hour markers all by yourself: Give it a go!
- You can notice the hours hand also advances proportionally to the number of elapsed minutes in the current hour. Can you implement it?
- Add a milliseconds hand!
- Build a chronometer!
Pro tip: For milliseconds, don't set the interval to 1 millisecond. Try 10 or 50 or 100 instead: It'll look and feel smoother, and you'll save a lot of useless compute. For the user anything below 300ms feels instant, so work with that!
Happy coding!
PS: I've included my final refactored version of the code (that gets rid of magic numbers, among other things), if you want to have a peek!