How to Add Ripple Effect to UI Elements in Your Vue.js App
Tags: webdev, javascript, tutorial, vue
Published: 2020-01-19 01:22:04

**Subscribe to my email list now at http://jauyeung.net/subscribe/**

**Follow me on Twitter at https://twitter.com/AuMayeung**

**Many more articles at https://medium.com/@hohanga**

A ripple effect is a situation where something starts inward and move outward. In a web app, this is the situation where the color changes from the inside and then spreads outward. You can add the ripple effect to your Vue.js app with the Vue-Ripple-Directive. More details about it are located at [https://github.com/PygmySlowLoris/vue-ripple-directive](https://github.com/PygmySlowLoris/vue-ripple-directive).

In this article, we will make a grocery list app where users can search for dishes they want to cook with the MealDB API and add the ingredients they want from it. We will add ripple effects to buttons and list items to highlight them when the mouse pointer goes over them.

To start building the app, we run the Vue CLI by running `npx @vue/cli grocery-app` . In the wizard, select ‘Manually select features’, then select Babel, Vuex, Vue Router, and CSS Preprocessor. Next, we install some packages that we need. We need Axios for making HTTP requests, BootstrapVue for styling, Vue Font Awesome for adding icons, Vee-Validate for form validation and Vue Ripple Directive for adding the ripple effect. To install them, we run:

```bash
npm i axios @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/vue-fontawesome axios bootstrap-vue vee-validate vue-ripple-directive
```

With all the packages installed, we can start writing the app. We start by adding a form for adding groceries. Create a `GroceryForm.vue` file in the components folder and add:

```javascript
<template>
  <div>
    <ValidationObserver ref="observer" v-slot="{ invalid }">
      <b-form @submit.prevent="onSubmit" novalidate>
        <b-form-group label="Search for Dish and Add Ingredients From Result">
          <ValidationProvider name="keyword" rules="required" v-slot="{ errors }">
            <b-form-input
              type="text"
              :state="errors.length == 0"
              v-model="form.keyword"
              required
              placeholder="Search for Dish and Add Ingredients From Result"
              name="keyword"
            ></b-form-input>
            <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>
        <b-button v-ripple.mouseover.500 type="submit" variant="primary">Find Dishes</b-button>
      </b-form>
    </ValidationObserver>
    <b-card v-for="(m, i) in meals" :title="m.strMeal" :key="i">
      <b-card-text>
        <b-list-group>
          <b-list-group-item
            v-for="(key, index) in Object.keys(m).filter(k => k.includes('strIngredient') && m[k])"
            :key="index"
            v-ripple.mouseover="'rgba(255, 255, 255, 0.35)'"
          >{{m[key]}}</b-list-group-item>
        </b-list-group>
      </b-card-text>
      <b-button
        v-ripple.mouseover.500
        variant="primary"
        @click="addToGroceryList(i)"
      >Add Ingredients to Grocery List</b-button>
    </b-card>
    <h4>Your Chosen Ingredients</h4>
    <b-list-group>
      <b-list-group-item v-for="(ingredient, i) of ingredients" :key="i" v-ripple.mouseover>
        {{ingredient}}
        <font-awesome-icon icon="times" class="float-right" @click="removeIngredient(i)" />
      </b-list-group-item>
    </b-list-group>
<br />
<b-button v-ripple.mouseover.500 type="button" variant="primary" @click="saveGroceryList()">Save</b-button>
    <b-button v-ripple.mouseover.500 type="reset" variant="danger" @click="cancel()">Cancel</b-button>
  </div>
</template>
<script>
import { requestsMixin } from "@/mixins/requestsMixin";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
export default {
  name: "GroceryForm",
  mixins: [requestsMixin],
  components: {
    faTimes
  },
  data() {
    return {
      form: {},
      meals: [],
      ingredients: []
    };
  },
  computed: {
    grocery() {
      return this.$store.state.grocery;
    }
  },
  methods: {
    cancel() {
      this.$emit("cancelled");
    },
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      const { data } = await this.findDishes(this.form.keyword);
      this.meals = data.meals;
    },
    addToGroceryList(index) {
      const meal = this.meals[index];
      const keys = Object.keys(meal).filter(
        k => k.includes("strIngredient") && meal[k]
      );
      const ingredients = keys.map(k => meal[k]);
      this.ingredients = Array.from(
        new Set(this.ingredients.concat(ingredients))
      );
    },
    removeIngredient(index) {
      this.ingredients.splice(index, 1);
    },
    async saveGroceryList() {
      const payload = { ingredients: this.ingredients };
      if (!this.groceryListId) {
        await this.addGrocery(payload);
      } else {
        await this.editGrocery(payload);
      }
      const { data } = await this.getGrocery();
      this.$store.commit("setGrocery", data);
      this.$emit("saved");
    }
  },
  watch: {
    grocery: {
      handler(val) {
        this.ingredients = val.ingredients || [];
      },
      deep: true,
      immediate: true
    }
  }
};
</script>
<style lang="scss" scoped>
.delete {
  cursor: pointer;
}
</style>
```

This form lets users search for dishes with the given keyword, then return a list of ingredients for the dishes and then the user can add them to a list with the duplicates removed. 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. In the `b-form-input` components. We also add Vee-Validate validation to make sure that users have filled out the date before submitting it. We make the `keyword` field required in the `rules` prop so that users will have to enter something before searching.

We have buttons in the list at the bottom of the form, which has the list of ingredients, to delete each of them. This is why we imported the `faTimes` icon here, which displays as an ‘x’, so that users can click on it and delete it. If that element is clicked, the `removeIngredient` function is called. Then the user clicks Save at the bottom of the form, then the `saveGroceryList` function is called, which saves the list to our back end.

In this component, we also have a `watch` block to watch the `grocery` value, which is obtained from the Vuex store that we have to build. We get the latest list of ingredients as the `grocery` value is updated.

We have the ripple effect applied to the buttons and the list rows with Vue Ripple. `v-ripple.mouseover.500` means that the ripple effect will show for 500 milliseconds when the mouse is over the element with this directive. To apply a different color to the ripple effect than the default we can also specify the color value in the parameter of the directive as we have in `v-ripple.mouseover=”’rgba(255, 255, 255, 0.35)’”` . The ripple will have the color specified.

We style the element for deleting the form with `cursor:pointer` so the mouse icon will show up a hand instead of the arrow.

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

```javascript
const APIURL = "http://localhost:3000";
const MEAL_DB_URL = "https://www.themealdb.com/api/json/v1/1/search.php?s=";
const axios = require("axios");
export const requestsMixin = {
  methods: {
    getGrocery() {
      return axios.get(`${APIURL}/grocery`);
    },
    addGrocery(data) {
      return axios.post(`${APIURL}/grocery`, data);
    },
    editGrocery(data) {
      return axios.put(`${APIURL}/grocery`, data);
    },
    findDishes(keyword) {
      return axios.get(`${MEAL_DB_URL}${keyword}`);
    }
  }
};
```

These are the functions we use in our components to make HTTP requests to get and save our grocery data and search the Meal DB API for dishes.

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

```javascript
<template>
  <div class="page">
    <h1 class="text-center">Grocery List</h1>
    <b-button-toolbar class="button-toolbar">
      <b-button
        v-ripple.mouseover.500
        @click="openAddModal()"
        variant="primary"
      >Add Ingredients to Grocery List</b-button>
    </b-button-toolbar>
    <h4>Your Grocery List</h4>
    <b-list-group>
      <b-list-group-item
        v-for="(ingredient, i) of grocery.ingredients"
        :key="i"
        v-ripple.mouseover="'rgba(255, 255, 255, 0.35)'"
      >
        {{ingredient}}
        <font-awesome-icon icon="times" class="float-right" @click="removeIngredient(i)" />
      </b-list-group-item>
    </b-list-group>
    <b-modal id="add-modal" title="Add Ingredients to Grocery List" hide-footer>
      <GroceryForm
        @saved="closeModal()"
        @cancelled="closeModal()"
        :edit="false"
        :groceryListId="grocery.id"
      />
    </b-modal>
  </div>
</template>
<script>
// @ is an alias to /src
import GroceryForm from "@/components/GroceryForm.vue";
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
  name: "home",
  components: {
    GroceryForm
  },
  mixins: [requestsMixin],
  computed: {
    grocery() {
      return this.$store.state.grocery;
    }
  },
  data() {
    return {
      ingredients: []
    };
  },
  beforeMount() {
    this.getGroceryList();
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
    },
    async getGroceryList() {
      const { data } = await this.getGrocery();
      this.$store.commit("setGrocery", data);
    },
    async removeIngredient(index) {
      this.ingredients.splice(index, 1);
      const payload = { id: this.grocery.id, ingredients: this.ingredients };
      await this.editGrocery(payload);
      const { data } = await this.getGrocery();
      this.$store.commit("setGrocery", data);
    }
  },
  watch: {
    grocery: {
      handler(val) {
        this.ingredients = val.ingredients || [];
      },
      deep: true,
      immediate: true
    }
  }
};
</script>
};
```

This is the component of the home page. We display the list of ingredients chosen obtained from our back end here. Also, we have a button to open a modal with the `GroceryForm` that we created earlier to add ingredients to our grocery list. Getting data is done in the `getGroceryList` function. We put the obtained data into our Vuex store in the last line of the function.

Also, we let users remove ingredients that they saved to the list in this page with the `removeIngredient` function. We call `splice` on the `this.ingredients` array, which we got from the `grocery` state in the store then set to the current value in the `handler` of the `watch` block of `grocery`.

Again, we have the ripple effect applied to the buttons and the list rows with Vue Ripple. `v-ripple.mouseover.500` to show the ripple effect for 500 milliseconds for the buttons and `v-ripple.mouseover=”’rgba(255, 255, 255, 0.35)’”` . The ripple will have the color specified in the list items like we did in `GroceryForm`.

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

```javascript
<template>
  <div id="app">
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand to="/">Grocery List 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;
}
button,
.btn.btn-primary {
  margin-right: 10px !important;
}
.button-toolbar {
  margin-bottom: 10px;
}
</style>
```

to add a Bootstrap navigation bar to the top of our pages, and a `router-view` to display the routes we define. This `style` section isn’t scoped so the styles will apply globally. In the `.page` selector, we add some padding to our pages. We add some padding to the buttons in the remaining `style` code.

Then in `main.js` , replace the existing code with:

```javascript
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required, min_value, max_value } from "vee-validate/dist/rules";
import Ripple from "vue-ripple-directive";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
library.add(faTimes);
Vue.component("font-awesome-icon", FontAwesomeIcon);
Vue.directive("ripple", Ripple);
extend("required", required);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(BootstrapVue);
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, the Vue-Ripple library, and the Vue Font Awesome packages here. The `faTimes` is added to our app with the `library.add` function of Vue Font Awesome so that we can use it in our app.

In `router.js` we replace the existing code with:

```javascript
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:

```javascript
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default new Vuex.Store({
  state: {
    grocery: {}
  },
  mutations: {
    setGrocery(state, payload) {
      state.grocery = payload;
    }
  },
  actions: {}
});
```

to add our grocery state to the store so we can observe it in the `computed` block of `GroceryForm` and `HomePage` components. We have the `setGrocery` function to update the `grocery` state and we use it in the components by call `this.$store.commit(“setGrocery”, data);` like we did in `GroceryForm` and `HomePage` .

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

```html
<!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>Grocery List App</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-ripple-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 of our app.

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:

```bash
json-server --watch db.json
```

In `db.json`, change the text to:

```json
{  
  "grocery": {}  
}
```

So we have the `grocery` endpoints defined in the `requests.js` available.