DEV Community

Duncan
Duncan

Posted on

Timer in HTML/CSS/JS without using timer functions

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
});

Enter fullscreen mode Exit fullscreen mode

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
  );
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Let's do it line-by-line.

bar.style.setProperty("animation-name", "_"); // Specify a junk animation name
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)