I’ll start of by making a new Vue app using the vue-cli with the following options: TyepeScript, Babel, no class component syntax.

First, I’ll create a component called Progress.vue in src/components . Inside, add the following minimal code - the explanation follows.

The way this is going to work is the user will specify two markers. They start of as null . These indicate which two points we want to track scroll progress between. For now, they will just be HTML elements selected by document.querySelector , but you could allow the user to pass these as props as well. We will store the user's scroll percentage in the data object.

Now, add the following code to App.vue , which we will use to test the <Progress /> component.

A lot of boilerplate, but nothing very exciting yet. We render a bunch of <div /> . Then <div id=”progress-marker-start”></div> — this is the point we will start tracking the scroll progress. Next we have some more <div /> , and the end point, <div id=”progress-marker-end”></div> .

This renders the following:

For the proof of concept, we will simply display a % in the top right corner as the user scrolls.

Calculating the Marker Offsets

There are a few strategies to calculate how far a user has scrolled. The one I have found to cover all the edge cases and work correctly requires three pieces of information to calculate:

The current position the users has scrolled to The y position of progress-marker-start , relative to the top of the viewport The y position of progress-marker-end , relative to the top of the viewport

Getting the current scroll position is trivial — we can use window.scrollY and call it a day. The other two y positions are a bit more tricky. A part of any algorithm to calculate the position of a HTML element will almost always include getBoundingClientRect() . My strategy is no different. At first, my strategy was to calculate the positions as follows:

This seemed fine, until you add some padding to the <div id="app" /> element - then document.body.getBoundingClientRect().top ceases to accurately reflect the top of the document! The following image illustrates this:

document.body.getBoundingClientRect().top does not consider margins

You can see 25px are not accounted for, indicated by the red arrow. The way I solved this was using document.documentElement.getBoundingClientRect().top . document.documentElement refers the <html /> element, and getBoundingClientRect().top returns 0:

Considers the margin correctly!

This is working great for me so far, but it’s possible there are caveats to this method too (eg, if someone decided to add margin to the HTML, which is not something I've seen very often, if ever).

With this knowledge, we can add a cute little method that will get the y offset for an element. Add a methods key with the following function in Progress.vue :

Calculating the Scroll Progress

I want to start counting scroll percentage not when the <div id=”progress-marker-start”></div> appears on screen, but when it disappears above the top of the viewport. So all we need do is taken the different in the y position of the <div id=”progress-marker-start”></div> and <div id=”progress-marker-end”></div> and subtract window.innerHeight . This will give us the total pixels we want to track the scroll progress over.

Using the App.vue I defined, this works out to around 2011px.

Finally, we can calculate the percentage progress by dividing the window.scrollY - offsetFromTop by the total. window.scrollY - offsetFromTop reflects the point when the start marker is above the top of the current viewport.

Putting this all together, we get the following function. Add it to Progress.vue under methods :

The last thing to do is to call this method when the user scrolls. This isn’t the final code — there are many improvements we will make — but the easiest way to test this is to add the event listener in a mounted hook in Progress.vue :

Working (with % scroll, no UI yet)

It works! There are tons of improvements we will make, but this is a great proof of concept.

The entire <script> tag of Progress.vue so far is as follows:

Extract the Logic out of the Component

Before we add a nice UI like Tech Crunch has, we can decouple the scroll logic from the component. Currently, if someone wants to use our scroll progress component, they need to pull in all of Vue — not ideal, if you site is not already using Vue. None of the logic we have written even uses any of Vue’s API.

Let’s extract the logic, and make <Progress /> a thin Vue wrapper connecting Vue and around the actual business logic. This gives us the option of releasing a framework-agonistic scroll progress library, which authors can then integrate to their Vue/React/Angular/whatever app.

I’ll create a progress.ts script in the components directory, and add the functions discussed above, making slight changes to their signatures:

Update Progress.vue . The <script> section of <Progress /> is now just a thin wrapper around the logic in progress.ts :

Everything is still working! Let’s add a circle UI that fills out as the users scrolls, like the one on Tech Crunch.

Some SVG

Turns out you cannot make a incrementally filling circle like the one we want with CSS alone. Or, at least, it’s not the best tool for the job. That honor goes to SVG. Before integrating the SVG circle animation into the app, we need to understand a bit about SVG.

A basic SVG circle is drawn as follows:

This appears like this:

Basic SVG Circle

The next property we are interested in is stroke-dasharray . You can read more about this one MDN, however the basic premise is you can draw the shape with dashes, instead of a solid border. Here are some examples:

Various values of stroke-dasharray

For our purpose, we want stroke-dasharray to equal the circumference of our circle. We can calculate this programmatically: 2 * Math.PI * radius (which we specified to be 50px). This works out to be 314px for our circle. The following snippets demonstrates this calculation:

Nothing looks different yet — still a red circle. However, now we can take advantage of another property, stroke-dashoffset . This sets the offset (from where the dash is drawn). Currently our circle is comprised of a single large dash, set using stroke-dasharray , that is the full circumference of the circle. Let's try some different numbers for stroke-dasharray and see how things change:

Incrementally changing stroke-dashoffset

As the stroke-dashoffset gets larger, the red circle is smaller! As it increases in size, the red border is almost entirely gone. So when the user has scrolled 0% of the area we are measuring, we want the stroke-dashoffset to be (circumference * progress), where progress is between 0 and 1. For example:

Close, but not quite. This offsets the circle by 25%, drawing the remaining 75%. We actually want the other way around — only to draw 25%. So our calculation becomes (circumference — (circumference * progress)):

Looks good!

Integrating the SVG Circle and with Logic

Now we can have all the pieces need to draw circle as we scroll: the percentage change, and an SVG we can update with JavaScript. Let’s do it. Update the <template> section of Progress.vue

Next, update the <style> tag. It is a lot more simple now - we do most of the styling in the SVG now.

Next, we have an update to progress.ts . I added an updateCircle function, which implements the calculation discussed above, and cover a few edge cases. I also made some small changes to getScrollPercentage . The now completed progress.ts file looks like this:

Lastly, let’s use the updateCircle function. Update the <script> section of Progress.vue :

I added a call to updateCircle in the scroll event listener's callback. I also remove the event listener on destroyed to avoid a potential memory leak. Now it works! As you scroll up and down, you can see the stroke of the circle grow and shrink, reflecting your progress between the two markers. Try the demo here.

One Last Improvement — requestAnimationFrame

To learn why this is a good to do, research requestAnimationFrame and passive event listeners - there is a lot of information out there. There are other improvements and optimizations that I might write about in a future article.

This article covered:

some caveats of getClientBoundingRect

using Vue as a thin wrapper around your business logic

basic SVG

A potential improvement would be to build a more attractive UI than just a red circle! If anyone builds something, please share it with me. This CodePen would be a good place to start.

The source code is available here and a working demo here.