website-logo

Implementing a read progress bar

Published: 26 Dec 2024

Updated: 26 Dec 2024

#ux-pattern#front-end#js#css#html#svelte

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.

Image showing the read progress for the page
Image showing the read progress for the page

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 of 1. 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 from 0 to 1 (inclusive); otherwise, it should be between 0 and max (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.

  1. Render a <progress> element with appropriate styling.
  2. 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.
  3. 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:

  1. Use the IntersectionObserver API to determine if the content container is within the viewport. This will also enable us to dynamically add or remove the scroll event listener based on whether the content container’s visibility.
  2. 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.
  3. 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 is 500px, and the blog post container starts at 600px.

We have three cases to consider:

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

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

  1. If the user is in between, then we will have a progress value between 0 and 1.
// 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!