Last night, I issued a challenge on Twitter:
Liquid error: internal
Today, I'm going to explain how I solved it.
Introduction
In the browser, there's different components that handle timing. JavaScript, of course, with the setInterval()
and setTimeout()
functions. The rendering engine also handles things that change over time: CSS Animations, which form the core of my solution.
Animations
- can be started and stopped with JavaScript
- have defined, editable durations
- trigger an event when they finish
As a bonus, you can make your timer's progress look pretty. I kept it pretty simplistic in my solution: just a solid blue bar.
HTML
First, let's get some markup going. We're going to need:
- an input element, to get the desired duration
- a button, so we can start the timer
- a an element to animate
<input
type="text"
id="time"
placeholder="0h0m0s"
pattern="(\d*h)*(\d*m)*(\d*s)*" required
>
<button id="start">Start</button>
<div id="bar"></div>
Note the placeholder
and pattern
properties on the input element. We're expecting a specific input format, and that's how we enforce it.
Now, we need to style it.
Styles
The important part is the #bar
. First, let's make it visible.
#bar {
background: blue;
height: 1em;
width: 100%;
margin-top: 2em;
}
Now, let's make it animate. First we need to define the animation - here, we're just playing with the width.
@keyframes timing {
from { width: 100%; }
to { width: 0; }
}
#bar {
background: blue;
height: 1em;
width: 100%;
margin-top: 2em;
}
And now we'll tell the bar to animate.
@keyframes timing {
from { width: 100%; }
to { width: 0; }
}
#bar {
background: blue;
height: 1em;
width: 100%;
margin-top: 2em;
animation: {
name: timing;
timing-function: linear;
duration: 5s;
play-state: paused;
}
}
Boom. That's all the CSS we need. (Well, this is SCSS, but you get the idea.)
Now let's script it up.
JavaScript
// let's get short names for the elements
let input = document.getElementById("time");
let startButton = document.getElementById("start");
let bar = document.getElementById("bar");
// Let's start the timer when someone clicks the start button.
startButton.addEventListener('click', () => {
// If the input's not valid, stop right here.
if (!input.validity.valid) return;
// Let's get the value and break it up into hours, minutes, and seconds
let times = input.value.match(/(\d*h)*(\d*m)*(\d*s)*/);
// And use math to get a seconds value for everything
let time = [times[3], times[2], times[1]]
.reduce((accum, curr, index) =>
accum + (curr ? curr : "").match(/\d*/)[0] * Math.pow(60, index), 0
);
// Set the animation duration and start it.
bar.style.animationDuration = `${time}s`;
bar.style.animationPlayState = "running";
});
// We need to listen for the animation ending
bar.addEventListener('animationend', () => {
alert('Timer ended');
// Reset the animation
bar.style.animationPlayState = "paused"; // We don't want to restart immediately
bar.style.setProperty("animation-name", "_"); // Specify a junk animation name
void bar.offsetWidth; // Force a reflow
bar.style.removeProperty("animation-name"); // Clear the animation name
});
There's a few bits in here that need further explanation.
How does this work?
let time = [times[3], times[2], times[1]]
.reduce((accum, curr, index) =>
accum + (curr ? curr : "").match(/\d*/)[0] * Math.pow(60, index), 0
);
First off, times[1]
is the hours from the input. times[2]
is the minutes and times[3]
is the seconds.
We feed in the provided time values in reverse order, so seconds is index 0, minutes is index 1, and hours is index 2. Then, using 0 as a starting value, we add the appropriate number of seconds for each component. A simpler way to write this would be:
let time = 0;
time += times[3].match(/\d*/)[0] * Math.pow(60, 0) // notice the power increases?
time += times[2].match(/\d*/)[0] * Math.pow(60, 1) // it's the index.
time += times[1].match(/\d*/)[0] * Math.pow(60, 2)
or even just
let time = 0;
time += times[3].match(/\d*/)[0] // 1 second per second
time += times[2].match(/\d*/)[0] * 60 // 60 seconds per minute
time += times[1].match(/\d*/)[0] * 3600 // 3600 seconds per hour
The other funky code is this part, from the animationEnd
handler.
bar.style.setProperty("animation-name", "_"); // Specify a junk animation name
void bar.offsetWidth; // Force a reflow
bar.style.removeProperty("animation-name"); // Clear the animation name
Let's do it line-by-line.
bar.style.setProperty("animation-name", "_"); // Specify a junk animation name
What's happening here, for anyone unfamiliar with CSS, is we're increasing the specificity of the animation-name
property. To the renderer, it's like we're covering up the value provided in the stylesheet with this junk value. The next time the page is re-rendered, it'll use this value.
void bar.offsetWidth; // Force a reflow
This is the weird one. There's a great explanation on Reddit about this but in essence, it tricks the browser into re-rendering the page by pretending to ask for a property value on an element that has a pending change already from the previous line. The browser has to re-render, re-calculate, before it can answer. Without this line, though, the browser will ignore the lines immediately preceding and following this one.
bar.style.removeProperty("animation-name"); // Clear the animation name
Remember the first line of this arcane bit of code, how it's covering up the value from the stylesheet? This removes that cover, re-exposing the value to the renderer.
Conclusion
I think that's about it. If there's any questions, drop a comment here or on the original Twitter thread and I'll do my best to answer.
Top comments (0)