Tell your users the remaining reading time, in CSS

Note: this demo works only in Chrome 94+

Recently, thanks to the new @scroll-timeline property available so far only in Chrome 94+, I have seen an interesting demo by @bramus where the user is aware of the time needed to read a page with a sticky progress bar that stretches horizontally, whose width is dependant on the current scrolling position of the root element.

I've extended this idea a little bit by coding an animated countdown of the exact time needed to read the page. All you need to do is to just set the time in minutes and the CSS animations will do the work for you.

Try the demo (scroll the page until the end) or jump to the video at the end of the article:

How does it work?

Here is a step-by-step explanation

The variables

Basically, two variables --ss and --mm hold the values of seconds and minutes and two distinct animations change them. In order to make the interpolation of the values work in the keyframes, these variables have been registered as integers properties through the Houdini's API integrated on Chrome.

So the --ss variable is declared in the CSS like this:

@property --ss {
  syntax: "<integer>";
  initial-value: 0;
  inherits: true;
}

while the --mm is declared instead in a <style> block, inside the markup because here is where we are going to set the minutes needed (e.g. 7):

<style>
    @property --mm {
       syntax: "<integer>";
       initial-value: 7;
       inherits: true;
    }
</style>

Since it's not (yet) possibile to print out the value of a variable using the content property I've set two counters to print their values through the counter() function (with a leading 0 when necessary)

counter-reset: ss var(--ss);
...
content: counter(ss, decimal-leading-zero);

and

counter-reset: mm var(--mm);
...
content: counter(mm, decimal-leading-zero);

The scroll-timeline

I've set the scroll-timeline animation in this way

@scroll-timeline scroll {
  time-range: 60s;
  start: 200px;
  end  : calc(100% - 150px);
}

The time-range has been set to 60 seconds in order to make the calculations of the animations easier (at least for my brain) .

To give a meaningful example let's say that we want to start the countdown after 200px of scroll — to exclude the title or maybe a hero image, but you could even set it to 0 or to another value of course — and we want to also stop it before a hypothetical footer 150px tall.

The animations

This is the most intriguing part due to the tricky computations. Here is the animation of the minutes

@keyframes mins {
  to   { --mm: 0;  }
}

...
animation: mins 
   /* duration   */ 60s 
   /* timing     */ steps(var(--mm), start) 
   /* delay      */ calc(calc(60s / (calc(var(--mm)) - 1)) / 60 / var(--mm))
   /* repetition */ 1
   /* fill mode  */ forwards;

The @keyframes start from the value we have previously set and should decrease until 0, so the definition of the last keyframe is enough.

The animation should takes 60s which is the same amount of time we defined for the @scroll-timeline. The delay expression is calculated so that the value of the minutes decreases immediately after 1s of reading time (e.g. switching from 7:00 to 6:59), which is good to point out, it is not one second of animation (we will discuss about this in the next section).

The animation of seconds is quite similar but in this case we need to specify the starting value

@keyframes secs {
  from { --ss: 59; }
  to   { --ss: 0;  }
}

...
animation: secs 
   /* duration   */ calc(60s / calc(var(--mm)))
   /* timing     */ linear
   /* delay      */ calc(calc(60s / (calc(var(--mm)) - 1)) / 60 / var(--mm))
   /* repetition */ var(--mm) 
   /* fill-mode  */ forwards;

The only relevant difference here is the duration: seconds need to be updated faster than minutes, running --mm times from 59 to 0 with a repetition of --mm. Here the purpose of the delay is just to wait the first update of the minutes.

About the delay

I know that the value of the delay could look odd at first glance:

calc(calc(60s / (calc(var(--mm)) - 1)) / 60 / var(--mm))

but the reason is quite simple: the full animation runs in 60s and we need to wait 1s of reading time.

  • 1 minute of reading time is mapped to 60s / (--mm - 1) of the animation (because the value of minutes changes immediately by -1);
  • 1 second of reading time is 1/60 of the value we have just obtained, so we divide it by 60 and we get 60s / (--mm - 1) / 60;
  • Finally that delay should be taken into account just once and not at every loop of seconds, so we need to further divide this value by the number of cycles, which is exactly the value in minutes so the final expression we need is 60s / (--mm - 1) / 60 / --mm.

Hope it makes sense (and, in any case, if it works you don't need to touch it) :)

Always respect user preferences

The update of the seconds is amazing but it can be annoying for users who don't like continuous animations and that prefer a reduced motion.

We may use a simple mediaquery:

@media (prefers-reduced-motion: reduce) {
  .countdown__display--ss {
    display: none;
  }
}

and hide the element with the seconds.

“Ta-da!”

Wall-E

Final words & thoughts

Feel free to follow me on Codepen or Twitter where I usually talk about frontend and trees. Furthemore some days ago I wrote a related article about @scroll-timeline:

Note: This is a carbon free article so I pledge to plant 25 trees with Ecologi and I'll plant one extra tree for each retweet of the announcement below👇 until Jul 31th, 2021.

(up to 75 trees, retweets from human accounts with 20+ followers)

37