21
Creating a Stopwatch in React.js and CSS
Once upon a time I was interviewing for a Principal Front-end Engineer role and was challenged to create an analog clock in vanilla JS. In real time. I was given a little less than an hour to complete the exercise, but crushed it in under 15 minutes. I had never coded a clock before, and was shocked how easy it was. Recently, I found myself wanting to continue that exercise, but with something more complex, and maybe even interactive.
I decided a stopwatch would be the perfect micro-project. The math was basic, but there were some concepts that were new and maybe even tricky.
If you're still here, let's walk through the project.
The first thing you need to think about when starting on any application is: What is this thing even going to do? What are its functions? After refreshing my knowledge of stopwatch functionality by looking at several online (because what normal person is just using stopwatches regularly?), I decided that my basic requirements should be:
- Start a timer by tracking the number of milliseconds elapsed between the start time and "now."
- Mark any number of laps, which simply involves saving a list of arbitrary timestamps.
- Stop (pause) the timer.
- Resume the stopped timer.
- Reset the timer to 0.
With those requirements in mind, this is what our API looks like:
const Stopwatch = () => {
const start = () => {} // also restarts
const mark = () => {}
const stop = () => {}
const reset = () => {}
}
The next thing you need to think about is UI functionality. What will the user see? How will she interact with the application? From our functional requirements, I knew I'd need:
- Elapsed time readout.
- List of marked laps.
- Controls to start, stop, resume, and reset the timer, and to mark laps.
From these visual requirements we can infer the basic components we'll need.
For pure visual coolness, I want to show the elapsed time as an analog stopwatch with minute, second, and millisecond hands spinning around the center.
Watch hands, one each to indicate minutes, seconds, and milliseconds. These were abstracted into a general
Hand
component. Each hand will have some common styling, but will be differentiated by color. At any given time, each hand will be rotated according to its value, which we'll achieve by an inline style that applies a transform rule withtranslateX(-50%)
, androtate
set to the applicable value via avalue
prop.-
Tick marks: A ring of light gray tick marks are spaced for each second (1/60), darker and thicker tick marks at 5-second intervals, and darker and even thicker tick marks at 15-second intervals. I used zero HTML/JSX elements to make these. They are created using a conical gradient in CSS applied to the
::before
pseudo element of the watch face. This was admittedly a little tricky to figure out at first, but the math was ultimately embarassingly simple:- 1-second intervals: 360 degrees in a circle divided by 60 seconds in a minute = a tick mark every 1.67 degrees
- 5-second intervals: 360/12 = every 30 degrees
- 15-second intervals: 360/4 = every 90 degrees
Thus, my 3 repeating conical gradients in CSS:
background-image: repeating-conic-gradient(
from 359deg,
#555 0 2deg, transparent 2deg 90deg
), repeating-conic-gradient(
from 359.5deg,
#555 0 1deg, transparent 1deg 30deg
), repeating-conic-gradient(
from 359.75deg,
#ccc 0 .5deg, transparent .5deg 6deg
);
Then I'd need a mask to turn these gradients into tick marks by obscuring (or masking) all but the ends of them:
mask-image: radial-gradient(
circle at center,
transparent 66%,
#fff 66.1%
);
I'd need a button bar to show our controls
- Start button to start the timer. This button serves double duty as the "Lap" button while the timer is running.
- Stop button that pauses the timer.
- Reset button that completely resets the Stopwatch component to its original "zeroed" state.
In addition to the analog clock to also show elapsed time, I decided to add a digital readout (in MM:SS:ss format), because it's more readable. Oddly, this is the meatiest part of our code: converting our elapsed time in milliseconds to the whole minutes, whole seconds, and remaining milliseconds.
I would need to get only the whole minutes and seconds, no remainders and nothing less than 0. I ensure the former by applying Math.floor(value)
to always round down to the nearest whole number, and the latter by applying Math.max(0, value)
to replace any value less than zero with zero. I saved this as a convenience function, and define some useful constants:
const getNumOrZero = num => Math.floor(Math.max(0, num))
const ONE_SECOND_MS = 1000
const ONE_MINUTE_MS = ONE_SECOND_MS * 60
Now to get the whole minutes value, I could simply divide the total elapsed
milliseconds by the number of milliseconds in a minute (ONE_MINUTE_MS
), rounding down to get the whole minutes without the remainder (Math.floor
via getNumOrZero()
):
const wholeMinutesValue = getNumOrZero(elapsed / ONE_MINUTE_MS)
I'll need this value back in milliseconds later, so I can simply multiply it by ONE_MINUTE_MS
:
const wholeMinutesInMs = wholeMinutesValue * ONE_MINUTE_MS
I then do the same thing to get the whole seconds. I divide the total elapsed
milliseconds, minus the wholeMinutesInMs
calculated above, by ONE_SECOND_MS
(milliseconds in a second). This gives me the number of whole seconds remaining after subtracting the whole minutes:
const wholeSecondsValue = getNumOrZero((elapsed - wholeMinutesInMs) / ONE_SECOND_MS)
const wholeSecondsInMs = wholeSecondsValue * ONE_SECOND_MS
I can easily get the remaining milliseconds after subtracting the wholeMinutesInMs
and wholeSecondsInMs
from the total elapsed time in milliseconds:
const millisecsValue = elapsed - wholeMinutesInMs - wholeSecondsInMs
Now I could easily assemble my digital readout, being sure to left pad the minutes and seconds values with a zero for values < 10:
const elapsedFormatted = `${wholeMinutesValue.toString().padStart(2, '0')}:` +
`${wholeSecondsValue.toString().padStart(2, '0')}:` +
`${millisecsValue.toString().padStart(3, '0')}`
And I can render this:
The last UI component is a list of marked laps. I used an ordered list, but in reverse order so that the most recent lap is at the top of the list.
<ol className="time lap" reversed>{ lapList }</ol>
lapList
is an array of lap timestamps in the same MM:SS:ss format as the digital readout. Note the reversed
HTML attribute, which (as you might suspect) reverses the order of an ordered list.
And just for fun, I added a dark mode by abstracting the colors into SCSS variables and toggling a class:
I'm pretty pleased with how it turned out. The code is totally straightforward, but if you have any questions just drop them in a comment below!
21