React
09 July, 2019

Creating a reading progress bar in React

Creating a reading progress bar in React

Reading progress bars, like the one you can find on my blog at the top of single posts, are a nice little addition to give detailed information about how far the reader has progressed on the current post. The scrolling bar is not really meaningful in this regard; it includes your entire page, which means that your header, comments, footer, etc. are part of the indication.

Creating a reading progress bar which tells you the actual progress of just the current post content in React is quite easy - especially with hooks, which make our component even smaller.

The ReadingProgress component

Our ReadingProgress component will do the following things:

  • make use of the useState hook which will be responsible for reading and setting our reading progress
  • make use of the useEffect hook which will be responsible for handling the scroll event and properly update our progress bar on scroll
  • return the reading progress bar with the proper width

So let's dive right into the implementation:

const ReadingProgress = ({ target }) => {
  const [readingProgress, setReadingProgress] = useState(0);
    
  return <div className={`reading-progress-bar`} style={{width: `${readingProgress}%` }} />
};

This is the foundation for our component. readingProgress will be used as width (in percent) for our progress bar. The only prop for our component is target, which will be a reference to our DOM container of the post - more on that in a few moments.

First let's implement our listener, which will update our progress bar on scroll events:

const scrollListener = () => {
    if (!target.current) {
      return;
    }

    const element         = target.current;
    const totalHeight     = element.clientHeight - element.offsetTop;
    const windowScrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;

    if (windowScrollTop === 0) {
      return setReadingProgress(0);
    }

    if (windowScrollTop > totalHeight) {
      return setReadingProgress(100);
    }
    
    console.log(windowScrollTop);

    setReadingProgress((windowScrollTop / totalHeight) * 100);
  };
Will be placed within our ReadingProgress component.

windowScrollTop tries a bunch of different values which fixes undefined values for some browsers (e.g. Safari).

There's one problem with this implementation: 100% reading progress is only achieved if we've scrolled past our target. That's pretty unlikely to be true (except you scroll one line after finished reading one line, which would make you really weird) - so we need to slightly adjust how our reading progress is calculated:

const totalHeight     = element.clientHeight - element.offsetTop - window.innerHeight;

This should yield to a more accurate result in terms of when the bar should show finished.

Next we'll put our listener into a useEffect hook, which makes our entire component to look like this:

const ReadingProgress = ({ target }) => {
  const [readingProgress, setReadingProgress] = useState(0);
  const scrollListener = () => {
    if (!target.current) {
      return;
    }

    const element         = target.current;
    const totalHeight     = element.clientHeight - element.offsetTop - (window.innerHeight);
    const windowScrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;

    if (windowScrollTop === 0) {
      return setReadingProgress(0);
    }

    if (windowScrollTop > totalHeight) {
      return setReadingProgress(100);
    }

    setReadingProgress((windowScrollTop / totalHeight) * 100);
  };
  
  useEffect(() => {
    window.addEventListener("scroll", scrollListener);
    return () => window.removeEventListener("scroll", scrollListener);
  });

  return <div className={`reading-progress-bar`} style={{width: `${readingProgress}%`}} />;
};

The returned function from our useEffect hook is basically just what happens when the component is unmounted (see Effects with Cleanup in the docs).

Last but not least we need to use our component somewhere. At this point we'll need create a ref on our target container and simply pass this to our ReadingProgress component:

function App() {
  const target = React.createRef();
  return (
    <>
      <ReadingProgress target={target} />
      <div className={`post`} ref={target}>post content</div>
    </>
  );
}
See the docs for further information about createRef

Now your reading progress bar should work perfectly fine - except that you can't see it because it has no height. Fix this by adding some CSS:

.reading-progress-bar {
  position: sticky;
  height: 5px;
  top: 0;
  background-color: #ff0000;
}

Done! Now your readers no longer get lost in the sheer unending length of your posts and always know when it'll be over.

To see a fully working example you can take a look at this code pen:

Third party packages

There are some third party packages out there which handle this exact problem. As far as I've found out most of them are outdated and/or no longer maintained - but what's even more relevant at this point: do you really need a third party dependency for a really simple component with around 30 lines of code? Well, honestly, I don't think so.

Conclusion

As you've seen implementing a reading progress bar in React is pretty easy. Thanks to hooks we can implement this component as a very small function component with little to no overhead.

If you've got any improvements regarding the implementation or think this could be done better please let me know in the comments!

Kevin

Kevin

I make stuff. Mostly functional, occasionally shiny, stuff.