Building a reading progress bar in CSS
A reading progress bar is a great way to show your readers how far they are in an article. In this tutorial, we'll build a reading progress bar in CSS.
What reading progress bar?
When I mention "reading progress bar" in the title, I mean the little bar you see at the top of the screen that grows horizontally as you scroll through a blog post to indicate how far you have read the post. I have come across a few blogs that have this feature so I am excited about implementing one on my own blog as a nice little challenge. So let's get started!
The plan
Let's first break down the task of "building a reading progress bar" into a few smaller tasks and solve them one by one:
-
To build a progress bar, we first need to know the "progress". Here it means how far the user has scrolled through the blog post.
-
Creating the progress bar: Then we create a progress bar and make sure it works by feeding it a dummy progress, effectively separating this small task from the previous one.
-
Combining the first two together: By making the progress bar react to the actual progress the user has.
How far did we scroll?
Let's try to approach the first problem. Intuitively the progress bar should have no length when the user just started reading the article and at full length (page width) when the user "finished scrolling" (hopefully that also means finished reading) the article. For this I recalled there are a few attributes in document.documentElement
that has something to do with scroll
, height
and top
and client
which could be relevant to this task we have at hand. They are:
scrollHeight
scrollTop
clientTop
clientHeight
I know it's very hard to figure out what they actually are by simply looking at their names and at some point I'm convinced that their names are made by picking any 2 out of those 4 words and piece them together. While for sure there are documentations like MDN which explains each of them in depth, I sometimes find it even harder to comprehend those explanations than finding out their meaning empirically:
First lets pick a page that can be scrolled for experimenting. For this I choose one of the blog posts I wrote. Then we simply log the value of these 4 variables at different scrolling point to observe their behaviour.
At the top of the page, their values are:
scrollHeight
: 4756,scrollTop
: 0,clientTop
: 0,clientHeight
: 867
At the (somewhat) middle of the page, their values are:
scrollHeight
: 4756,scrollTop
: 1843,clientTop
: 0,clientHeight
: 867
At the bottom of the page (when you scroll down till you can't anymore), the values are:
scrollHeight
: 4756,scrollTop
: 3889,clientTop
: 0,clientHeight
: 867
From what I see here:
clientTop
is always 0 no matter where you are on the page. It's therefore not relevant to our task.scrollHeight
stays the same with a very huge number, so probably it refers to the total height of the blog post.scrollTop
gets bigger as I scroll down, so probably it has something to do with the progress.- Although
scrollTop
isn't the same asscrollHeight
when the bottom is reached, addingclientHeight
to it equals exactly toscrollHeight
: 3889 (scrollTop
) + 867 (clientHeight
) = 4756 (scrollHeight
). Therefore I suspect thatscrollTop
means the top of the viewport andclientHeight
is actually the height of the viewport. So now on my mind:
But maybe it's just coincidence, that's keep the scrolling position of the document but reduce the height of the browser and see how the values changes:
scrollHeight
: 4756 -> 4756,scrollTop
: 3293 -> 3293,clientHeight
: 867 -> 621
Only clientHeight
's value being reduced when the browser's height is reduced, which confirms the assumption.
As a result, we know that at the top of the page, scrollTop
should be 0; When we are at the bottom of the page, scrollTop
should be scrollHeight - clientHeight
:
As a result, the "percentage" of paragraph read can be defined as:
Creating the Progress bar
After figuring out how to calculate the progress of the reading, we now can turn to create the progress bar component.
I imagine the progress bar will stick right below the header and it will be "hidden" by the dropdown menu when the menu is opened so the new component should be placed within the header component:
1export const Header = () => {
2 const [menuOpened, setMenuOpened] = useState(false);
3 return (
4 <>
5 <header
6 className={classNames(
7 "sticky top-0 flex justify-between items-center p-4 bg-white dark:bg-slate z-50"
8 )}
9 >
10 <ReadingProgressBar />
11 <Brand />
12 <div className="hidden md:flex items-center gap-8">
13 {/* ... */}
14 );
15}
16
Then unleash our creativity and fill in the <ReadingProgressBar />
, assuming the progress is 50% so we see how the bar would look like in the filled part as well as the remaining part:
export const ReadingProgressBar = () => {
<div className="absolute top-full left-0 h-1 w-1/2 bg-primary" />
}
Now add an event listener to listen to scroll event. Calculate the "progress" and console.log
it whenever a scroll happens:
1export const ReadingProgressBar = () => {
2 useEffect(() => {
3 const scrollListener = () => {
4 const pageHeight = document.documentElement.scrollHeight;
5 const viewportHeight = document.documentElement.clientHeight;
6 const currentScrollPosition = document.documentElement.scrollTop;
7 const ratio = currentScrollPosition / (pageHeight - viewportHeight);
8 console.log({ ratio });
9 };
10 document.addEventListener("scroll", scrollListener);
11 return () => document.removeEventListener("scroll", scrollListener);
12 }, []);
13 <div className="absolute top-full left-0 h-1 w-1/2 bg-primary" />
14 }
15
Make the bar react to the changing progress
Once we know it's working when the ratio
logged makes sense (which should be between 0 and 1, and increases when the page is scrolled towards the bottom), we change the width of the progress bar according to the ratio:
1export const ReadingProgressBar = () => {
2 const progressBar = useRef<HTMLDivElement>(null);
3 useEffect(() => {
4 const scrollListener = () => {
5 if(!progressBar.current) return;
6 const pageHeight = document.documentElement.scrollHeight;
7 const viewportHeight = document.documentElement.clientHeight;
8 const currentScrollPosition = document.documentElement.scrollTop;
9 const ratio = currentScrollPosition / (pageHeight - viewportHeight);
10 progressBar.current.style.width = `${ratio * 100}%`;
11 };
12 document.addEventListener("scroll", scrollListener);
13 return () => document.removeEventListener("scroll", scrollListener);
14 }, []);
15 <div ref={progressBar} className="absolute top-full left-0 h-1 w-1/2 bg-primary" />
16 }
17
So far so good, but I want like to add a little bit of (almost transparent) tint to the entire progress bar so to give the readers an idea of where the progress bar is going to go. Additionally it when the users haven't started reading (i.e. progress is almost 0) yet the entire proress bar and the tint should be invisible. Only when the users actually started reading will the tint fades in and the progress bar starts to move:
To do this, let's wrap the "moveable" progress bar into a container, which always spans across the width of the screen but remains invisible when the progress (ratio) is almost 0. Once the ratio starts to be significant (say 0.01) will the tint appears:
1export const ReadingProgressBar = () => {
2 const progressBar = useRef<HTMLDivElement>(null);
3 const progressBarContainer = useRef<HTMLDivElement>(null);
4 useEffect(() => {
5 const scrollListener = () => {
6 if (!progressBar.current || !progressBarContainer.current) return;
7 const pageHeight = document.documentElement.scrollHeight;
8 const viewportHeight = document.documentElement.clientHeight;
9 const currentScrollPosition = document.documentElement.scrollTop;
10 const ratio = currentScrollPosition / (pageHeight - viewportHeight);
11 progressBarContainer.current.style.backgroundColor =
12 ratio < 0.01 ? "transparent" : `${colors.slate[500]}30`;
13 progressBar.current.style.width = `${ratio * 100}%`;
14 };
15 document.addEventListener("scroll", scrollListener);
16 return () => document.removeEventListener("scroll", scrollListener);
17 }, [inBlogPost]);
18
19return (
20
21<div
22role="presentation"
23ref={progressBarContainer}
24className={classNames(
25"absolute top-full left-0 right-0 h-1 md:h-2 -z-30 transition-colors duration-300"
26)}>
27<div
28 role="progressbar"
29 ref={progressBar}
30 className="z-10 absolute top-0 left-0 h-full bg-primary "
31 />
32</div>
33);
34}
35
Conclusion
At this stage the progress bar should be reacting to the reading progress of the user. If you want to have a demo, just look at the bottom of the header bar of the screen :)