Creating a Scroll-Based Progress Bar in Vue

Feb 11 2019 | By Taha Shashtari

Sometimes we need a way to show users how much they have read from an article. And the best way to do this is by showing a progress bar that shows this in percentage.

To achieve this, we have to know how much they have scrolled through that article (or anything), then use this to calculate the completed percentage. We also need a component to display that percentage to the user.

If you're interested in learning how to build this, check out the following demo on CodePen, then keep reading to see how to build it.

Step 1: Create App.vue

We're going to use Vue CLI 3's Instant Prototyping to run this project. So create App.vue, and put the following into the <template> section:

<template>
  <div id="app">
    <div class="card">
      <progress-bar :value="progress"/>
      <div class="text-section">
        Lorem ipsum dolor sit amet, consectetur adipisicing elit. Cupiditate architecto voluptatum, laboriosam quisquam quod minus molestias. Rem ut quidem, corrupti nihil molestiae deserunt, iusto velit unde atque mollitia, eum ad maiores exercitationem. Soluta harum sit cupiditate eos, commodi itaque nihil, beatae dolorem ducimus, repudiandae, vero quo corporis sed laborum at maxime dicta dolores perferendis! Possimus repellat velit iste quod recusandae suscipit vitae ex soluta nostrum animi saepe eius itaque, voluptas, sapiente minima quo culpa explicabo necessitatibus distinctio. Veritatis amet tempora, consectetur molestias optio eveniet laudantium, tenetur aspernatur nobis ratione sit hic in impedit quod deserunt recusandae atque, ipsam molestiae sequi!
      </div>
    </div>
  </div>
</template>

We're here displaying a card element with some text. We are also displaying the progress bar component (which we haven't created yet) above it.

In the <script> section, put this:

<script>
import ProgressBar from './ProgressBar'

export default {
  components: { ProgressBar },
  data () {
    return {
      progress: 0
    }
  }
}
</script>

We'll use the progress data property to store how much the user has scrolled so far. It will be from 0-1.

Now we move to the <style> section:

<style>
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  padding: 1px 0 0;
  width: 100vw;
  height: 100vh;
  background: #6B5CA5;
}

html {
  padding: 0;
  margin: 0;
}

.card {
  border-radius: 3px;
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  line-height: 1.5;
  font-size: 18px;
  color: #444;
  width: 300px;
  margin: 10px auto;
  height: 150px;
  margin-top: 10px;
  display: flex;
  flex-direction: column;
  background: #FFFFFF;
  box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
}

.text-section {
  height: 100%;
  max-height: 100%;
  padding: 0 10px 10px;
  overflow: scroll;
  -webkit-overflow-scrolling: touch;
}
</style>

Step 2: Create ProgressBar.vue

Create ProgressBar.vue and put this into <template>:

<template>
  <div class="progress-bar">
    <div class="filled-bar"></div>
    <span class="percentage-text">
      Progress: {{ percentageText }}
    </span>
  </div>
</template>

This component consists of three parts:

  • .progress-bar is the container of the progress bar — we specify the width and the height of the progress bar here.
  • .filled-bar will have the same width and height of its container, but it will be filled with a background color.
  • .percentage-text displays the current scroll percentage.

.filled-bar and .percentage-text will be positioned absolutely. And, obviously, .percentage-text should have a higher z-index to appear above .filled-bar.

As mentioned above, .filled-bar will have the same width as its container. This means that we'll use its position to reflect the current scroll percentage. So, when the user is at the very top (hasn't scrolled yet), we'll move the filled bar outside the progress bar container completely (from the left side). And as the user is scrolling down, we'll move the filled bar to the right, to gradually enter the progress bar container.

But of course the user shouldn't see the outside part of the filled bar. So we have to add overflow: hidden to the container (.progress-bar).

Now, let's write the <script> section:

<script>
export default {
  props: {
    value: {
      type: Number,
      default: 0
    }
  },

  computed: {
    percentageText () {
      return `${Math.round(this.value * 100)}%`
    }
  }
}
</script>

Note how we're accepting the current progress value through value prop.

As you can see in the computed section, we've used that value to get the percentage value as formatted text. So, for example, if the value is 0.05, it should output 5%.

Next, let's add the styling:

<style scoped>
.progress-bar {
  position: relative;
  height: 30px;
  width: 100%;
  border-bottom: 1px solid #f0f0f0;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
  border-radius: 3px 3px 0 0;
}

.percentage-text {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 2;
  font-size: 14px;
  transform: translate3d(-50%, -50%, 0);
}

.filled-bar {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  height: 100%;
  width: 100%;
  background: #eef0ff;
}
</style>

Now if you view your work in the browser, you would see something like this:

We still have two things left to get this done:

  1. Update the filled bar position according to the current progress value (value prop).
  2. Calculate the scroll percentage as the user's scrolling. We'll pass this value to the progress bar through the value prop.

Step 3: Update the filled bar position

We will use transform: translate3d(x, y, z) CSS property to change the filled bar position.

Two things to notice here. First, we will only need to update the x value and keep the other two 0. Second, we will use translate3d instead of translateX for performance reasons — to let the GPU handle it.

Now when x is 0, the filled bar will appear in its original position (the user will see that the progress is completely filled). To move it completely to the left (outside the container), we should set it to -100%. This means, x will be from 0%-100%.

We can use transform on the filled bar simply by binding it to its style property:

<div
  class="filled-bar"
  :style="{ transform: `translate3d(-${(1 - value) * 100}%, 0, 0)` }"
></div>

You can test that the progress bar is working properly by changing the default value of the value prop. Try to set it to 0.5, and you should see it like this:

Step 4: Calculate the current scroll percentage

To get this value, we have to, first, listen to the @scroll event on .text-section, and then, divide the current scroll position (in pixels) by the full height of the text section.

So, in App.vue, add @scroll and ref to .text-section, like this:

<div
  class="text-section"
  ref="text"
  @scroll="onScroll"
>

We needed to add ref to access the element's scroll position and height values.

Next, implement the onScroll method:

methods: {
  onScroll () {
    const progress = this.$refs.text.scrollTop / (this.$refs.text.scrollHeight - this.$refs.text.clientHeight)
    if (progress > 1) {
      this.progress = 1
    } else if (progress < 0) {
      this.progress = 0
    } else {
      this.progress = progress
    }
  }
}

Because of the momentum scrolling on mobiles, we made sure that the progress value is not above 1 or below 0 — otherwise, the progress bar would look broken.

So, when the user scrolls, we update the progress value in App.vue; then, pass it to the progress bar component (through value), which would then update the filled bar position and reproduce the formatted percentage text.