Implementing a read progress bar
Published: 26 Dec 2024
Updated: 26 Dec 2024
If you pay attention to the top of this page while scrolling through the article, you’ll notice an element displaying your reading progress. At first glance, a feature like this might not seem very useful, but it can be incredibly helpful for visitors reading long articles, especially when scrollbars are turned off or not always visible—such as on a phone. You might think it’s just a horizontal scrollbar replicating the vertical scrollbar’s functionality, but that’s not the case (as you’ll see in the implementation). This element can also be used to show progress for a specific section of the page. So, let’s take a look at how to implement something like this.
Choosing the correct HTML element
My initial idea for implementing the visual indicator was to create a <div>
wrapping another <div>
so that I could adjust the width of the child <div>
as the user scrolls.
For example,
<div class="read-progress-container">
<!-- Using JS manipulate the width for this div as the user is scrolling -->
<div class="read-progress"></div>
</div>
I was able to implement a working solution using the approach above (with the JavaScript part described later in this post), but something felt off as I started wondering whether I was using the correct semantic HTML element for the use case. So, I started researching and came across the <progress>
HTML element, which can be used to display an indicator showing the completion progress of a task. It made perfect sense to use this instead.
<progress>
element
Here is quick crash course on the <progress>
element, but I encourage you to read the official doc to learn more.
<progress max="1" value="0.5"></progress>
Attributes:
max
- An optional attribute with a default value of1
. If specified, it must be greater than 0 and a valid floating-point number.value
- Specifies the current completion progress for the task. If no max value is set, the value can range from0
to1
(inclusive); otherwise, it should be between0
andmax
(inclusive). If the value attribute is omitted, the progress bar is rendered in an indeterminate state, with no indication of how long it will take to complete.
Implementing a resuable component
❗ I use Svelte to write components for my website. However, the approach described here can easily be adapted to other frameworks (for example, by using the
useEffect
hook in React) or even implemented with vanilla JavaScript.
Before diving into the implementation, let’s clearly define how this feature should work. Doing so will help us choose the right tools to build the final experience.
- Render a
<progress>
element with appropriate styling. - Ensure the progress bar updates only when the content container (e.g., the blog post container) is within the viewport and being scrolled. The update process should be as efficient and performant as possible.
- Implement the function to update the progress bar.
Now, let’s take a closer look at each of these steps one at a time.
Rendering the <progress>
element
I chose to implement it as a fixed bar at the top of the page, but feel free to experiment with different designs and placements.
<!-- File: ReadBar.svelte -->
<script lang="ts">
// use a reactive state value, which can be updated later to re-render the component
let progress = $state(0);
// more code later...
</script>
<!-- "value" will be updated to use a reactive state value in the following section -->
<progress value={progress} class="read-progress-bar"></progress>
<!-- choose an appropriate "z-index" value as per your app -->
<style>
.read-progress-bar {
top: 0;
left: 0;
z-index: 2;
height: 4px;
width: 100%;
position: fixed;
}
</style>
Setting up the event handlers
Here’s what we’ll do:
- Use the
IntersectionObserver
API to determine if the content container is within the viewport. This will also enable us to dynamically add or remove thescroll
event listener based on whether the content container’s visibility. - To avoid making excessive updates during scrolling, we’ll leverage the
window.requestAnimationFrame
API. This API is designed for animations and visual updates, ensuring smooth rendering by synchronizing with the browser’s refresh rate. - Utilize the appropriate component lifecycle methods to set up and clean up our handlers efficiently.
<!-- File: ReadBar.svelte -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
let animationFrameId: number;
let observer: IntersectionObserver | null;
// add a prop to the component to ensure reusability
let { containerId }: { containerId: string } = $props();
function handleScroll() {
// Use `requestAnimationFrame` to flush updates.
animationFrameId = window.requestAnimationFrame(updateReadProgress);
}
function updateReadProgress() {
// refer to the next section
}
// setup the event handlers on mount
onMount(() => {
const blogContainer = document.getElementById(containerId);
if (!blogContainer) {
return;
}
// instantiate an observer to observe the content container
observer = new IntersectionObserver(
([entry]) => {
/**
* If the content container is in the viewport:
* - Yes: add the scroll listener
* - No: remove the scroll listener
*/
if (entry?.isIntersecting) {
// flush an update as soon as the post is visible
// Example: if this post is already visible on first mount
updateReadProgress();
window.addEventListener('scroll', handleScroll);
} else {
window.removeEventListener('scroll', handleScroll);
}
},
{
/**
* for this feature:
* - An intersection can be defined as soon as a single pixel comes into the viewport
*/
threshold: [0],
},
);
observer.observe(blogContainer);
});
// perform appropriate cleanup when the component is destroyed
onDestroy(() => {
observer?.disconnect();
});
</script>
Updating the progress bar
We can define the reading progress as follows:
// value range: [0,1]
const progress = readContentHeight / totalContentHeight;
To compute the value of these variables, we can use the getBoundingClientRect()
API.
function updateReadProgress() {
// remember: `containerId` is passed as a prop
const containerEle = document.getElementById(containerId);
// the value `top` will change whenever the user will scroll
const { top, height: totalContentHeight } =
containerEle.getBoundingClientRect();
// Why this would work? See explanation below.
const readContentHeight = window.innerHeight - top;
const progress = readContentHeight / totalContentHeight;
}
Computing the totalContentHeight
is straightforward, so let’s take a closer look at how we calculate readContentHeight
, which essentially represents how much content has been read by the user.
At any given time, the amount of content visible to the user depends on the window’s height, so that part should be clear. To understand why we need to subtract the top
value, let’s consider an example.
Let’s say our post has a total height of
800px
, the browser window height is500px
, and the blog post container starts at600px
.
We have three cases to consider:
- If the content container is yet to be scrolled into the view (below the viewport), then progress value will be
0
, i.e., user is yet to see the post.
Based on our IntersectionObserver implementation, we’ll only attach the scroll listener once the element is in view.
- If the content container is scrolled past (above the viewport), then the progress value will be
1
, meaning the user has read the entire post.
Based on our IntersectionObserver implementation, we’ll remove the scroll listener once the element is out of the view.
- If the user is in between, then we will have a progress value between
0
and1
.
// top value will be < 0 because user has scrolled past the element's starting point
// for example, top is -100 px, i.e., element starts at 100 px above the current position
const contentReadHeight = Math.max(0, 500 - (-100)); // 600;
// user is still reading the post and has made 0.75 progress, i.e., 75% of the article is read
const progress = Math.min(1, 600 / 800); // 0.75;
Finally, you might be wondering why we didn’t calculate the percentage value for progress. The reason is that the <progress>
element’s value
attribute range is [0, 1]
.
Final component code
<!-- File: ReadBar.svelte -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
let animationFrameId: number;
let observer: IntersectionObserver | null;
let { containerId }: { containerId: string } = $props();
let progress = $state(0);
function handleScroll() {
animationFrameId = window.requestAnimationFrame(updateReadProgress);
}
function updateReadProgress() {
const blogContainer = document.getElementById(containerId) as HTMLElement;
const { top, height } = blogContainer.getBoundingClientRect();
progress = (window.innerHeight - top) / height;
}
onMount(() => {
const blogContainer = document.getElementById(containerId);
if (!blogContainer) {
return;
}
observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
updateReadProgress();
window.addEventListener('scroll', handleScroll);
} else {
window.cancelAnimationFrame(animationFrameId);
window.removeEventListener('scroll', handleScroll);
}
},
{
threshold: [0],
},
);
observer.observe(blogContainer);
});
onDestroy(() => {
observer?.disconnect();
});
</script>
<progress value={progress} class="fixed top-0 left-0 w-full z-10 h-1"
></progress>
Conclusion
Developing this feature gave me the opportunity to use the <progress>
element in a real-world scenario and refresh my knowledge of some fundamental DOM APIs. I hope this indicator proves helpful to my readers, especially when navigating longer articles.
Keep reading and coding!