loading...
Cover image for Pimp your Blog: Make a Reading Progress Bar 💅🎩

Pimp your Blog: Make a Reading Progress Bar 💅🎩

robole profile image Rob OLeary Updated on ・5 min read

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>

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

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;

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

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! 👩‍💻👨‍💻🙌

Posted on by:

robole profile

Rob OLeary

@robole

Hacker, traveller, photographer, tinkerer

Discussion

pic
Editor guide
 

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.

 

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

 

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!

 

Thank you for sharing.

 

Youre welcome Habdul 🙂

 

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