How to Add Scroll to Top Feature in Your Vue.js App

If a page has a long list, then it is convenient for users if the page has an element that scrolls to somewhere on the page with one click. In plain JavaScript, there is the window.scrollTo and element.scrollTo functions which take the x, y coordinate of the screen as parameters, which isn’t too practical for most cases. There’s also the scrollIntoView function available for DOM element objects. You can call it to scroll to the element that’s calling this function.

With Vue.js, we can do this easily with the Vue-ScrollTo directive located at https://github.com/rigor789/vue-scrollTo. It allows us to scroll to an element identified by ID of an element and also add animation to the scrolling. It makes implementing this feature.

In this article, we will build a recipe app that has tool tips to guide users on how to add recipes into a form. Users can enter the name of their dish, the ingredients, the steps and upload a photo. In the entry, there will be a ‘Scroll to Top’ button to let the user scroll back to the top automatically by clicking the button. We will build the app with Vue.js.

We start building the app by running the Vue CLI. We run it by entering:

npx @vue/cli create recipe-app

Then select ‘Manually select features’. Next, we select Babel, Vue Router, Vuex, and CSS Preprocessor in the list. After that, we install a few packages. We will install Axios for making HTTP requests to our back end. BootstrapVue for styling, V-Tooltip for the tooltips, Vue-ScrollTo for scrolling and Vee-Validate for form validation. We install the packages by running npm i axios bootstrap-vue v-tooltip vee-validate vue-scrollto .

Now we move on to creating the components. Create a file called RecipeForm.vue in the components folder and add:

<template>
  <ValidationObserver ref="observer" v-slot="{ invalid }">
    <b-form @submit.prevent="onSubmit" novalidate>
      <b-form-group
        label="Name"
        v-tooltip="{
          content: 'Enter Your Recipe Name Here',
          classes: ['info'],
          targetClasses: ['it-has-a-tooltip'],
        }"
      >
        <ValidationProvider name="name" rules="required" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.name"
            required
            placeholder="Name"
            name="name"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">Name is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group
        label="Ingredients"
        v-tooltip="{
          content: 'Enter Your Recipe Description Here',
          classes: ['info'],
          targetClasses: ['it-has-a-tooltip'],
        }"
      >
        <ValidationProvider name="ingredients" rules="required" v-slot="{ errors }">
          <b-form-textarea
            :state="errors.length == 0"
            v-model="form.ingredients"
            required
            placeholder="Ingredients"
            name="ingredients"
            rows="8"
          ></b-form-textarea>
          <b-form-invalid-feedback :state="errors.length == 0">Ingredients is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group
        label="Recipe"
        v-tooltip="{
          content: 'Enter Your Recipe Here',
          classes: ['info'],
          targetClasses: ['it-has-a-tooltip'],
        }"
      >
        <ValidationProvider name="recipe" rules="required" v-slot="{ errors }">
          <b-form-textarea
            :state="errors.length == 0"
            v-model="form.recipe"
            required
            placeholder="Recipe"
            name="recipe"
            rows="15"
          ></b-form-textarea>
          <b-form-invalid-feedback :state="errors.length == 0">Recipe is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-form-group label="Photo">
        <input type="file" style="display: none" ref="file" @change="onChangeFileUpload($event)" />
        <b-button
          @click="$refs.file.click()"
          v-tooltip="{
            content: 'Upload Photo of Your Dish Here',
            classes: ['info'],
            targetClasses: ['it-has-a-tooltip'],
          }"
        >Upload Photo</b-button>
      </b-form-group>

      <img ref="photo" :src="form.photo" class="photo" />

      <br />

       <b-button type="submit" variant="primary" style="margin-right: 10px">Submit</b-button>
      <b-button type="reset" variant="danger" @click="cancel()">Cancel</b-button>
    </b-form>
  </ValidationObserver>
</template>

<script>
import { requestsMixin } from "@/mixins/requestsMixin";

export default {
  name: "RecipeForm",
  mixins: [requestsMixin],
  props: {
    edit: Boolean,
    recipe: Object
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid || !this.form.photo) {
        return;
      }

      if (this.edit) {
        await this.editRecipe(this.form);
      } else {
        await this.addRecipe(this.form);
      }
      const { data } = await this.getRecipes();
      this.$store.commit("setRecipes", data);
      this.$emit("saved");
    },
    cancel() {
      this.$emit("cancelled");
    },
    onChangeFileUpload($event) {
      const file = $event.target.files[0];
      const reader = new FileReader();
      reader.onload = () => {
        this.$refs.photo.src = reader.result;
        this.form.photo = reader.result;
      };
      reader.readAsDataURL(file);
    }
  },
  data() {
    return {
      form: {}
    };
  },
  watch: {
    recipe: {
      handler(val) {
        this.form = JSON.parse(JSON.stringify(val || {}));
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

<style>
.photo {
  width: 100%;
  margin-bottom: 10px;
}
</style>

In this file, we have a form to let users enter their recipe. We have text inputs and a file upload file to let users upload a photo. We use Vee-Validate to validate our inputs. We use the ValidationObserver component to watch for the validity of the form inside the component and ValidationProvider to check for the validation rule of the inputted value of the input inside the component. Inside the ValidationProvider , we have our BootstrapVue input for the text input fields.

Each form field has a tooltip with additional instructions. The v-tooltip directive is provided by the V-Tooltip library. We set the content of the tooltip and the classes here, and we can set other options like delay in displaying, the position and the background color of the tooltip. A full list of options is available at https://github.com/Akryum/v-tooltip.

The photo upload works by letting users open the file upload dialog with the Upload Photo button. The button would click on the hidden file input when the Upload Photo button is clicked. After the user selects a file, then the onChangeFileUpload function is called. In this function, we have the FileReader object which sets the src attribute of the img tag to show the uploaded image, and also the this.form.photo field. readAsDataUrl reads the image into a string so we can submit it without extra effort.

This form is also used for editing recipes, so we have a watch block to watch for the recipe prop, which we will pass into this component when there is something to be edited.

Next we create a mixins folder and add requestsMixin.js into the mixins folder. In the file, we add:

const APIURL = "[http://localhost:3000](http://localhost:3000)";
const axios = require("axios");

export const requestsMixin = {
  methods: {
    getRecipes() {
      return axios.get(`${APIURL}/recipes`);
    },

    addRecipe(data) {
      return axios.post(`${APIURL}/recipes`, data);
    },

    editRecipe(data) {
      return axios.put(`${APIURL}/recipes/${data.id}`, data);
    },

    deleteRecipe(id) {
      return axios.delete(`${APIURL}/recipes/${id}`);
    }
  }
};

These are the functions we use in our components to make HTTP requests to get and save our data.

Next in Home.vue , replace the existing code with:

<template>
  <div class="page" id='top'>
    <h1 class="text-center">Recipes</h1>
    <b-button-toolbar class="button-toolbar">
      <b-button @click="openAddModal()" variant="primary">Add Recipe</b-button>
    </b-button-toolbar>

    <b-card
      v-for="r in recipes"
      :key="r.id"
      :title="r.name"
      :img-src="r.photo"
      img-alt="Image"
      img-top
      tag="article"
      class="recipe-card"
      img-bottom
    >
      <b-card-text>
        <h1>Ingredients</h1>
        <div class="wrap">{{r.ingredients}}</div>
      </b-card-text>

      <b-card-text>
        <h1>Recipe</h1>
        <div class="wrap">{{r.recipe}}</div>
      </b-card-text>

      <b-button
        href="#"
        v-scroll-to="{
          el: '#top',
          container: 'body',
          duration: 500,
          easing: 'linear',
          offset: -200,
          force: true,
          cancelable: true,
          x: false,
          y: true
        }"
        variant="primary"
      >Scroll to Top</b-button>

      <b-button @click="openEditModal(r)" variant="primary">Edit</b-button>

      <b-button @click="deleteOneRecipe(r.id)" variant="danger">Delete</b-button>
    </b-card>

    <b-modal id="add-modal" title="Add Recipe" hide-footer>
      <RecipeForm @saved="closeModal()" @cancelled="closeModal()" :edit="false" />
    </b-modal>

    <b-modal id="edit-modal" title="Edit Recipe" hide-footer>
      <RecipeForm
        @saved="closeModal()"
        @cancelled="closeModal()"
        :edit="true"
        :recipe="selectedRecipe"
      />
    </b-modal>
  </div>
</template>

<script>
// @ is an alias to /src
import RecipeForm from "@/components/RecipeForm.vue";
import { requestsMixin } from "@/mixins/requestsMixin";

export default {
  name: "home",
  components: {
    RecipeForm
  },
  mixins: [requestsMixin],
  computed: {
    recipes() {
      return this.$store.state.recipes;
    }
  },
  beforeMount() {
    this.getAllRecipes();
  },
  data() {
    return {
      selectedRecipe: {}
    };
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    openEditModal(recipe) {
      this.$bvModal.show("edit-modal");
      this.selectedRecipe = recipe;
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
      this.$bvModal.hide("edit-modal");
      this.selectedRecipe = {};
    },
    async deleteOneRecipe(id) {
      await this.deleteRecipe(id);
      this.getAllRecipes();
    },
    async getAllRecipes() {
      const { data } = await this.getRecipes();
      this.$store.commit("setRecipes", data);
    }
  }
};
</script>

<style scoped>
.recipe-card {
  width: 95vw;
  margin: 0 auto;
  max-width: 700px;
}

.wrap {
  white-space: pre-wrap;
}
</style>

In this file, we have a list of BootstrapVue cards to display a list of recipe entries and let users open and close the add and edit modals. We have buttons on each card to let users edit or delete each entry. Each card has an image of the recipe at the bottom which was uploaded when the recipe is entered. For scrolling to top functionality, we used the v-scroll-to directive provided by the V-ScrollTo library. To make scrolling smooth, we set the easing property to linear . Also, we set the duration of the scroll to 500 milliseconds. el is the selector of the element we want to scroll to. Settingforce to true means that scrolling will be performed, even if the scroll target is already in view. cancelable is true means that the user can cancel scrolling. x set to false means that we don’t want to scroll horizontally, and y set to true means we want to scroll vertically. container is the selector for the container element that will be scrolled. offset is the offset in the number of pixels when scrolling. The full list of options is at https://github.com/rigor789/vue-scrollTo.

In the scripts section, we have the beforeMount hook to get all the password entries during page load with the getRecipes function we wrote in our mixin. When the Edit button is clicked, the selectedRecipe variable is set, and we pass it to the RecipeForm for editing.

To delete a recipe, we call deleteRecipe in our mixin to make the request to the back end.

The CSS in the wrap class is for rendering line break characters as line breaks.

Next in App.vue , we replace the existing code with:

<template>
  <div id="app">
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand to="/">Recipes App</b-navbar-brand>

<b-navbar-toggle target="nav-collapse"></b-navbar-toggle>

<b-collapse id="nav-collapse" is-nav>
        <b-navbar-nav>
          <b-nav-item to="/" :active="path  == '/'">Home</b-nav-item>
        </b-navbar-nav>
      </b-collapse>
    </b-navbar>
    <router-view />
  </div>
</template>

<script>
export default {
  data() {
    return {
      path: this.$route && this.$route.path
    };
  },
  watch: {
    $route(route) {
      this.path = route.path;
    }
  }
};
</script>

<style lang="scss">
.page {
  padding: 20px;
  margin: 0 auto;
  max-width: 700px;
}

button {
  margin-right: 10px !important;
}

.button-toolbar {
  margin-bottom: 10px;
}

.tooltip {
  display: block !important;
  z-index: 10000;

.tooltip-inner {
    background: black;
    color: white;
    border-radius: 16px;
    padding: 5px 10px 4px;
  }

.tooltip-arrow {
    width: 0;
    height: 0;
    border-style: solid;
    position: absolute;
    margin: 5px;
    border-color: black;
  }

&[x-placement^="top"] {
    margin-bottom: 5px;

.tooltip-arrow {
      border-width: 5px 5px 0 5px;
      border-left-color: transparent !important;
      border-right-color: transparent !important;
      border-bottom-color: transparent !important;
      bottom: -5px;
      left: calc(50% - 5px);
      margin-top: 0;
      margin-bottom: 0;
    }
  }

&[x-placement^="bottom"] {
    margin-top: 5px;

.tooltip-arrow {
      border-width: 0 5px 5px 5px;
      border-left-color: transparent !important;
      border-right-color: transparent !important;
      border-top-color: transparent !important;
      top: -5px;
      left: calc(50% - 5px);
      margin-top: 0;
      margin-bottom: 0;
    }
  }

&[x-placement^="right"] {
    margin-left: 5px;

.tooltip-arrow {
      border-width: 5px 5px 5px 0;
      border-left-color: transparent !important;
      border-top-color: transparent !important;
      border-bottom-color: transparent !important;
      left: -5px;
      top: calc(50% - 5px);
      margin-left: 0;
      margin-right: 0;
    }
  }

&[x-placement^="left"] {
    margin-right: 5px;

.tooltip-arrow {
      border-width: 5px 0 5px 5px;
      border-top-color: transparent !important;
      border-right-color: transparent !important;
      border-bottom-color: transparent !important;
      right: -5px;
      top: calc(50% - 5px);
      margin-left: 0;
      margin-right: 0;
    }
  }

&[aria-hidden="true"] {
    visibility: hidden;
    opacity: 0;
    transition: opacity 0.15s, visibility 0.15s;
  }

&[aria-hidden="false"] {
    visibility: visible;
    opacity: 1;
    transition: opacity 0.15s;
  }
}
</style>

to add a Bootstrap navigation bar to the top of our pages, and a router-view to display the routes we define. Also, we have the V-Tooltip styles in the style section. This style section isn’t scoped so the styles will apply globally. In the .page selector, we add some padding to our pages and set max-width to 700px so that the cards won’t be too wide. We also added some margins to our buttons.

Next in main.js , we replace the existing code with:

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import VTooltip from "v-tooltip";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
extend("required", required);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(BootstrapVue);
Vue.use(VTooltip);

Vue.config.productionTip = false;

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");

We added all the libraries we need here, including BootstrapVue JavaScript and CSS, Vee-Validate components along with the validation rules, and the V-Tooltip directive we used in the components.

In router.js we replace the existing code with:

import Vue from "vue";
import Router from "vue-router";
import Home from "./views/Home.vue";

Vue.use(Router);

export default new Router({
  mode: "history",
  base: process.env.BASE_URL,
  routes: [
    {
      path: "/",
      name: "home",
      component: Home
    }
  ]
});

to include the home page in our routes so users can see the page.

And in store.js , we replace the existing code with:

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    recipes: []
  },
  mutations: {
    setRecipes(state, payload) {
      state.recipes = payload;
    }
  },
  actions: {}
});

to add our recipes state to the store so we can observer it in the computed block of RecipeFormand HomePage components. We have the setRecipes function to update the passwords state and we use it in the components by call this.$store.commit(“setRecipes”, response.data); like we did in RecipeForm .

Finally, in index.html , we replace the existing code with:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
    <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
    <title>Recipe App</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-tooltip-tutorial-app doesn't work properly without
        JavaScript enabled. Please enable it to continue.</strong
      >
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

to change the title.

After all the hard work, we can start our app by running npm run serve .

To start the back end, we first install the json-server package by running npm i json-server. Then, go to our project folder and run:

json-server --watch db.json

In db.json, change the text to:

{
  "`recipes`": [
]
}

So we have the recipes endpoints defined in the requests.js available.