Make a reading progress bar for your blog 📊
Tags: webdev, javascript, css, tutorial
Published: 2020-04-21 12:10:09

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. 💜

{% codepen https://codepen.io/robjoeol/pen/bGVpePR %}

### HTML

```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](https://css-tricks.com/html5-progress-element/) 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.

```css
: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](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API), 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](https://caniuse.com/#feat=intersectionobserver).

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.

```javascript
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](https://dev-to-uploads.s3.amazonaws.com/i/mnpryryewjjquy7k5y90.png)

### Optimize the event handler

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

[Debouncing](https://css-tricks.com/debouncing-throttling-explained-examples/#article-header-id-0) 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](https://lodash.com/docs#debounce) and [Underscore](https://underscorejs.org/#debounce).
1. You can use the `requestAnimationFrame` callback.
1. 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.

```javascript
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](https://css-tricks.com/debouncing-throttling-explained-examples/).

### 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](https://dev-to-uploads.s3.amazonaws.com/i/31bghhba9pc4fwzpb1t5.png)

### Browser support

`requestAnimationFrame` works in all Browsers from IE10 and up. You can support older browsers with [this polyfill from Paul Irish](https://gist.github.com/paulirish/1579671), 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! 👩‍💻👨‍💻🙌

---
**Thank you for reading! Feel free to subscribe to my [RSS feed](https://www.roboleary.net/feed.xml), and share this article with others on social media. 💌**

**You can show your appreciation by buying me a coffee on [ko-fi](https://ko-fi.com/roboleary). 🙂**