An Idiosyncratic Blog

The Build Series: E5 - Page Scroll Component in React

Published on
9 minutes read

Intro

You might have seen websites with a progress bar at the top of the page which shows how much % of page a user has scrolled. Real fancy stuff. In this episode of The Build Series, we'll build a progress bar which shows the current scroll position of the page.

ProgressBar Indicator Component

First let's build a simple progress bar.

import React from 'react';
import from './ProgressIndicator.scss';

export const ProgressIndicator = ({ progress }) => {
  return (
    <div className="progress-wrapper">
      <div className="progress-track"
        style={{ width: `${progress}%` }}>

      </div>
    </div>
  );
};
.progress-wrapper {
  border-radius: 8px;
  border: 2px solid #444;
  box-sizing: border-box;
  height: 16px;
  margin-bottom: 8px;
  width: 100%;

  .progress-track {
    background-color: #61afef;
    border: 2px solid #222;
    border-radius: 9px;
    height: 8px;
    padding: 0;
    animation: progressBar 1.5s ease-in-out;
    animation-fill-mode: both;
  }
}

The ProgressBar component will look something like this, when progress = 40:

image-20210515005059625

Although the semantically correct way to build a progress bar is using <Progress> HTML tag, for the sake of ease of styling, we'll use <div> this time around.

Listen to Page Scroll

Next we need to listen to the page scroll event. We will use the scroll event and update the progress prop of the ProgressBar component. Since scroll events fire at a very high rate, we will also throttle the event using requestAnimationFrame().

Let's add a scroll listener, and because we are using React, we will wrap it within a useEffect.

useEffect(() => {
  document.addEventListener('scroll', listener) // highlight-line
  return () => {
    document.removeEventListener('scroll', listener)
  }
})

And our listener will call requestAnimationFrame and calculate the scroll distance.

const listener = () => {
  requestAnimationFrame(() => {
    calculateScrollDistance()
  })
}

Pretty straightforward all this. We now have a scroll event listener which fires an event whenever user scrolls the page. We also use requestAnimationFrame() method to tell the browser that we wish to perform an operation and requests that the browser calls a specified function to update before the next repaint.

Calculating Scroll Distance

Now this is a part where we use some math. To calculate how much % of a page user has scrolled, we will need reference to two things:

  • The height of the document
  • The current scroll location

Getting the height of the document is a tricky one. Different browsers have different methods of reporting scrollHeight. Although, Can I Use states scrollHeight is supported by all browsers, we will get all height values found on document and use the highest one for compatibility’s sake.

const getDocHeight = () => {
  return Math.max(
    document.body.scrollHeight,
    document.documentElement.scrollHeight,
    document.body.offsetHeight,
    document.documentElement.offsetHeight,
    document.body.clientHeight,
    document.documentElement.clientHeight
  )
}

And to calculate scroll distance:

const calculateScrollDistance = () => {
  const scrollTop = window.pageYOffset // how much the user has scrolled by
  const winHeight = window.innerHeight
  const docHeight = getDocHeight()

  const totalDocScrollLength = docHeight - winHeight
  const scrollPosition = Math.floor((scrollTop / totalDocScrollLength) * 100)

  return scrollPosition
}

Putting everything together in a hook,

export const usePageScroll = (handler) => {
  const calculateScrollDistance = () => {
    const scrollTop = window.pageYOffset // how much the user has scrolled by
    const winHeight = window.innerHeight
    const docHeight = getDocHeight()

    const totalDocScrollLength = docHeight - winHeight
    const scrollPosition = Math.floor((scrollTop / totalDocScrollLength) * 100)

    handler(scrollPosition)
  }

  const getDocHeight = () => {
    return Math.max(
      document.body.scrollHeight,
      document.documentElement.scrollHeight,
      document.body.offsetHeight,
      document.documentElement.offsetHeight,
      document.body.clientHeight,
      document.documentElement.clientHeight
    )
  }

  const listener = () => {
    requestAnimationFrame(() => {
      calculateScrollDistance()
    })
  }
  useEffect(() => {
    document.addEventListener('scroll', listener)
    return () => {
      document.removeEventListener('scroll', listener)
    }
  })
}

Using the hook

Let's use the usePageScroll hook. (See what I did there 😏). We maintain a progress state, which gets updated whenever user scrolls the page. This is passed as props to ProgressIndicator component, which updates the progress bar.

const repeater = Array.from({ length: 10 }, (i) => 'i')

const Page = () => {
  const [progress, setProgress] = useState(0) // Initial progress = 0;
  usePageScroll(setProgress)

  return (
    <div className="App">
      <div className="navigation-bar">
        <ProgressIndicator progress={progress} />
      </div>
      {repeater.map((_, i) => (
        <p key={i}>
          Aute officia nulla deserunt do deserunt cillum velit magna. Officia veniam culpa anim
          minim dolore labore pariatur voluptate id ad est duis quis velit dolor pariatur enim.
          Incididunt enim excepteur do veniam consequat culpa do voluptate dolor fugiat ad
          adipisicing sit. Labore officia est adipisicing dolore proident eiusmod exercitation
          deserunt ullamco anim do occaecat velit. Elit dolor consectetur proident sunt aliquip est
          do tempor quis aliqua culpa aute. Duis in tempor exercitation pariatur et adipisicing
          mollit irure tempor ut enim esse commodo laboris proident. Do excepteur laborum anim esse
          aliquip eu sit id Lorem incididunt elit irure ea nulla dolor et. Nulla amet fugiat qui
          minim deserunt enim eu cupidatat aute officia do velit ea reprehenderit. Voluptate
          cupidatat cillum elit quis ipsum eu voluptate fugiat consectetur enim. Quis ut voluptate
          culpa ex anim aute consectetur dolore proident voluptate exercitation eiusmod. Esse in do
          anim magna minim culpa sint. Adipisicing ipsum consectetur proident ullamco magna sit amet
          aliqua aute fugiat laborum exercitation duis et.
        </p>
      ))}
    </div>
  )
}

The end result should look something like this:

And there we have it! We've built a scroll indicator by leveraging the scroll events and some React hooks magic.

Bonus

See how the navigation bar is sticking to the top of the page, to achieve that we use a CSS property called position: sticky. The CSS for the navigation-bar class is:

.navigation {
  height: 18px;
  width: 100%;
  background-color: #191a1d;
  position: sticky;
  top: 0;
  padding: 12px 0;
}

That's it for this episode of The Build Series. Feel free to reach out if you need any help!

Until next time! ✌🏽