DEV Community

Cover image for Make a reading progress bar for your blog 📊
Rob OLeary
Rob OLeary

Posted on • Updated on • Originally published at roboleary.net

Make a reading progress bar for your blog 📊

Can we add anything to a standard blog that would enhance the reading experience?

How about a reading progress bar?

The Progress Bar

The progress bar is sticky and only appears when the post comes into view. Scroll down and you will see a funky purple bar as you go. 💜

HTML

<progress id="reading-progress" max="100" value="0" ></progress>
Enter fullscreen mode Exit fullscreen mode

I chose to use <progress>, this is a semantic HTML match for the job, swipe right! ✅
o
We use the following attributes:

  • max describes how much work the task requires. We set this to 100 and we have a range of 0 to 100 for possible values.
  • value specifies how much of the task has been completed. We give it an initial value of 0, and this is what we update in JavaScript as the user scrolls.

CSS

It is not trivial to style <progress>, you need to do a bit of extra work to use it, instead of reaching for a <div> as most people do! 🙄😄 You can read this article to understand the finer details.

We want the progress bar to stick to the top of the post, so we use the properties: position: sticky; and top: 0;. We use all the browser prefixes to avoid any compatibility hiccups.

For the styling of the bar itself, I have clarified what is what by using CSS variables, as you can see you need to cater to 3 different Browser groups for consistent styling, using different properties for the same outcome. It looks good in Firefox and Chrome for sure, I haven't checked it in other Browsers.

:root {
  --progress-width: 100%;
  --progress-height: 8px;
  --progress-bar-color: rgb(115, 0, 209);
  --progress-bg: none;
  --progress-border-radius: 5px;
}

progress {
  position: -moz-sticky;
  position: -ms-sticky;
  position: -o-sticky;
  position: -webkit-sticky;
  position: sticky;
  top: 0;
}

/*Target this for applying styles*/
progress[value] {
  /* Reset the default appearance */
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;

  /* Get rid of default border in Firefox. */
  border: none;

  width: var(--progress-width);
  height: var(--progress-height);

  /* Firefox: any style applied here applies to the container. */
  background-color: var(--progress-bg);
  border-radius: var(--progress-border-radius);

  /* For IE10 */
  color: var(--progress-bar-color);
}

/* For Firefox: progress bar */
progress[value]::-moz-progress-bar {
  background-color: var(--progress-bar-color);
  border-radius: var(--progress-border-radius);
}

/* WebKit/Blink browsers:
    -webkit-progress-bar is to style the container */
progress[value]::-webkit-progress-bar {
  background-color: var(--progress-bg);
  border-radius: var(--progress-border-radius);
}

/*-webkit-progress-value is to style the progress bar.*/
progress[value]::-webkit-progress-value {
  background-color: var(--progress-bar-color);
  border-radius: var(--progress-border-radius);
}
Enter fullscreen mode Exit fullscreen mode

JavaScript

The JavaScript is quite straightforward, and hopefully is self-explanatory! 😅

I use an Intersection Observer, which tells us when the post is in view. We use this to ensure that we only update the progress bar when it is in view. This API is very well-supported by Browsers now.

To find out what is our current position in the post, we check the top coordinate of its bounding box. If it is negative, then we have scrolled into (or past) our post by some amount, we take this value and divide it by the height of the bounding box to get the percentage scrolled.

The last piece is to add a scroll listener for the page (window), which calls our function to update the progress bar.

const post = document.getElementById("post");
const progress = document.getElementById("reading-progress");
let inViewport = false;

let observer = new IntersectionObserver(handler);

observer.observe(post);

//Whenever the post comes in or out of view, this handler is invoked.
function handler(entries, observer) {
    for (entry of entries) {
        if (entry.isIntersecting) {
          inViewport = true;
        } else {
          inViewport = false;
        }
    }
}

// Get the percentage scrolled of an element. It returns zero if its not in view.
function getScrollProgress(el) {
  let coords = el.getBoundingClientRect();
  let height = coords.height;
  let progressPercentage = 0;

  if (inViewport && coords.top < 0) {
    progressPercentage = (Math.abs(coords.top) / height) * 100;
  }

  return progressPercentage;
}

function showReadingProgress() {
    progress.setAttribute("value", getScrollProgress(post));
}

//scroll event listener
window.onscroll = showReadingProgress;
Enter fullscreen mode Exit fullscreen mode

Optimize the Code

The performance of our code is fine, but can be improved. If you are interested, read on!

There are 2 parts of our code that make it perform sub-optimally.

The first part is that some methods trigger the Browser to recalculate the layout (known as reflow in Mozilla's terminology). This is an expensive operation and should be done only when necessary. When we call getBoundingClientRect(), we trigger this.

The second part is that scroll events can fire at a high rate. If the event handler is executed at this rate, it can be wasteful.

So, what can we change?

Only trigger layout when necessary

We can change our logic a bit so that getBoundingClientRect() is only called when the post is in the viewport.

optimize code by moving getBoundingClientRect

Optimize the event handler

We want to limit how often the scroll event handler is called to update the progress bar.

Debouncing regulates the rate at which a function is executed over time, and is a common optimization technique.

We have a few options:

  1. You can use libraries that have a debounce function such as Lodash and Underscore.
  2. You can use the requestAnimationFrame callback.
  3. You can make your own debounce implementation.

The recommendation is to use requestAnimationFrame if you are "painting" or animating properties directly. We are changing the value property, which triggers painting, so we will go with it.

The advantage we gain with requestAnimationFrame is that the Browser executes changes the next time a page paint is requested, whereas with a debounce function it executes at a pre-determined rate that we pick.

The code change is quite small.

var timeout;

window.onscroll = function () {
    if (timeout) {
        window.cancelAnimationFrame(timeout);
    }

    timeout = window.requestAnimationFrame(function () {
        showReadingProgress();
  }); 
}
Enter fullscreen mode Exit fullscreen mode

I recommend this article if you would like to learn more about debouncing and requestAnimationFrame.

What's the performance gain?

I compared the performance for a fast scroll through the article from top to bottom. Here are the results from Google Devtools. You can see in the optimized code, it spends about 75% less time repainting.

performance comparison in chrome devtools

Browser support

requestAnimationFrame works in all Browsers from IE10 and up. You can support older browsers with this polyfill from Paul Irish, which falls back to setTimeout().

Final Words

Thanks for reading! If you enjoyed the post, let me know.

Maybe next, I will speak about calculating reading time for a blog post.

Happy hacking! 👩‍💻👨‍💻🙌

Discussion (9)

Collapse
orenmizr profile image
Oren Mizrahi

Hi Rob, enjoyed reading. shouldn't you de-bounce the scroll events. If i remember correctly there are too many on each scroll. for optimization purposes.

Collapse
robole profile image
Rob OLeary Author

I added a section about optimization if you are interested Oren!

Collapse
robole profile image
Rob OLeary Author • Edited

Thanks Oren. Yes, you could optimize the code by debouncing. Maybe I will add this to the article. I wanted to focus on explaining how to implement this without going too far into more complex territory!

Collapse
supermavster profile image
Miguel Ángel

Hello, first excellent post but I have a problem, I don't know what happens, help please:

Uncaught ReferenceError: entry is not defined  at IntersectionObserver.handler
Enter fullscreen mode Exit fullscreen mode
Collapse
supermavster profile image
Miguel Ángel

I found the error when I compress the code with a web package, the conversion is not the better. Only no compress the code or validate de data in the each

Collapse
robole profile image
Rob OLeary Author

Hi Miguel,

This is probably an issue with your webpack configuration. I wouldn't be able to say what the issue is without seeing what you're doing. Try posting your code on StackOverflow to get help there.

Collapse
robole profile image
Rob OLeary Author

Do you have any ideas about adding something to your blog?

Collapse
ziizium profile image
Habdul Hazeez

Thank you for sharing.

Collapse
robole profile image
Rob OLeary Author

Youre welcome Habdul 🙂