Creating page scroll indicator in a React
#Creating a page scroll indicator
#What do we have to do
Let's think for a few moments, what exactly we want to achieve and break it down to simple things. We want a progress bar. Progress written as a number basically means a certain fraction of a whole (so the % sounds like the best unit for that). Our whole would be an entire page scrolled down to the bottom. Our 0% would be our page not scrolled at all. Whenever our scrolling position changes, we need to calculate at what percentage of the page our scroll is.
#How to implement progress bar
Our actual progress bar, in terms of implementation, can be displayed as two divs, let's call them #outer
and #inner
. I'm sure you can achieve this type of behavior using some pseudoelements as well, but don't get distracted. We can think of optimizations later on. So we have the two divs. #outer
will always take entire available width (so 100%), while #inner
's width will be in range from 0% to 100%. #outer
can be transparent (or black), while #inner
must have some vibrant background color, to indicate the progress.
#How to measure scroll percentage
Browsers provide us, through Document API, with a few properties, that could help us. I'd argue that there are actually way too many properties and that it's no longer helpful. Let's take a quick look at them:
document.body.scrollHeight
- represents the entire height of the body of the document, including any content that overflows and is not currently visible in the viewport.document.body.offsetHeight
- the total height of the body element, including padding and borders, but excluding margins.document.documentElement.clientHeight
- the height of the viewport (the visible part of the document in the browser window) excluding the browser's chrome (like the address bar and toolbars).document.documentElement.scrollHeight
- the entire height of the document's root element (usually the<html>
element), including any content that overflows and is not currently visible in the viewport.document.documentElement.offsetHeight
- total height of the document's root element, including padding and borders but excluding margins.
That's crazy. I know.
We could use Math.max(...aboveValues)
to be safe... or just go with the document.documentElement.scrollHeight
since it's the most reliable value of them all. We are also gonna need the documentElement.clientHeight
, to calculate the total available space for our scroll to move through.
Why? Well, when we are at the top of the page, our scrolling position will be 0. Let's assume our browser viewport is 1000px and our total page height is 4000px. Now, when we will be at the bottom of the page, what do you think will be the value of the scroll position? Since you can't answer, I'll do it - it will be 3000px, because the scroll position value is referencing the top of the scrolled view, not the bottom of it. That's why our starting scroll position is 0.
So to get the total available space we will need to subtract scrollHeight
- clientHeight
.
#Getting our current scrolling position
But how to get the scrolling position in the first place? This would be much more straightforward. We can use the document.documentElement.scrollTop
property.
#Let's write some code
#React component boilerplate
We can begin from creating boilerplate of our component. As mentioned before, I'm gonna use two divs, one inside the other. I added the "use client"
directive, because I'm gonna use this component in the root component of the page, which is a server component.
ScrollIndicator.tsx'use client'; // if you're using the app router with RSC
function ScrollIndicator() {
return (
<div className='fixed top-0 left-0 z-20 bg-black w-full'>
<div className='h-2 bg-primary-500 w-0' />
</div>
);
}
export default ScrollIndicator;
I already added some basic styling in tailwind, but if you would rather stick to the old school CSS, here's the corresponding CSS code:
scroll-indicator.css#outer {
position: fixed;
top: 0;
left: 0;
z-index: 20; /* you can adjust it to your existing CSS elements placement */
background: black;
width: 100%;
}
#inner {
height: 0.5rem;
background: red; /* use whatever fits your website */
width: 0;
}
#Adding scroll event listener
The next step will be to add an event listener for scroll event of our window. In react, we can do it using useEffect
, like this:
function handleScrollEvent() {
console.log("I'm scrolling the page lol");
}
useEffect(() => {
window.addEventListener('scroll', handleScrollEvent);
return () => {
// that part is important, to avoid memory leaks
window.removeEventListener('scroll', handleScrollEvent);
};
}, []);
#Writing actual logic for determining scroll progress
Now it's time for some actual code. This would still be fairly simple.
ScrollIndicator.tsxfunction ScrollIndicator() {
const innerRef = useRef<HTMLDivElement>(null);
function handleScrollEvent() {
if (!innerRef.current) {
return;
}
const {
scrollTop: currentScrollPosition,
scrollHeight: totalPageHeight,
clientHeight: viewportHeight,
} = document.documentElement;
const availableScrollSpace = totalPageHeight - viewportHeight;
const scrollPercentage = (currentScrollPosition / availableScrollSpace) * 100;
innerRef.current.style.width = `${scrollPercentage}%`;
}
...
return (
<div className='...'>
<div className='...' ref={innerRef} />
</div>
);
}
Let's break this down.
I used useRef
to be able to dynamically assign style rules to my inner div. We could use useState
instead, and keep the percentage value there, but since we're gonna need this percentage only to set inner element's styles, there is no added benefit of keeping this value in state.
Since all the values that we need for calculations are coming from the document.documentElement
object, we can destructure them and assign them some more explanatory names. That's just syntactic sugar for
const currentScrollPosition = document.documentElement.scrollTop;
const totalPageHeight = document.documentElement.scrollHeight;
const viewportHeight = document.documentElement.clientHeight;
What's really important are these three lines:
const availableScrollSpace = totalPageHeight - viewportHeight;
const scrollPercentage = (currentScrollPosition / availableScrollSpace) * 100;
innerRef.current.style.width = `${scrollPercentage}%`;
We calculate the percentage, as described a few sections before, and then assign the value to the inner element's width CSS rule.
And that's it. This is how it looks like in action:
Final effect