54
loading...
This website collects cookies to deliver better user experience
assets
folder. If you used an icon library in Figma, you can simply click on each icon and export it to an SVG file.NavBar
for the top navigation bar and MegaMenu
which is the actual mega menu implementation.template
section consists of three main div
s containing the logo, main menu items, and primary icons. These div
s are wrapped in a parent container that uses a flex
display with a justify-content
of space-between
to evenly spread out the three sections. To simplify our code, we can take advantage of Vue.js’s v-for
directive to automatically render the menu items. The :hover
CSS selector is used to highlight the item that the user is interacting with.mouseover
event by binding it to a component method called mouseEvent
. The mouseEvent
method is triggered by elements of classes menu-container
and item
. That is because we need to know when a menu item is being hovered and when the mouse has moved elsewhere on the navigation bar.MegaMenu
component. The v-if
directive is bound to a reactive data variable named showMegaMenu
which is activated when the user hovers the Products menu item.style
section with the CSS.<template>
<div>
<div class="menu-container" @mouseover="mouseEvent($event, 'wrapper')">
<div class="logo">
<a href="https://tomaraei.com">
<img src="../assets/logo.svg" />
</a>
</div>
<div class="items">
<a
v-for="item in menuItems"
@mouseover="mouseEvent($event, 'item', item.key)"
:key="item.key"
class="item"
>
{{ item.label }}
</a>
</div>
<div class="icons">
<div class="icon">
<img src="../assets/magnifier.svg" />
</div>
<div class="icon">
<img src="../assets/users.svg" />
</div>
<div class="icon menu">
<img src="../assets/menu.svg" />
</div>
</div>
</div>
<MegaMenu v-if="showMegaMenu" />
<div class="viewport-warning">
<div class="message">
This example was made for viewport sizes 920px and above :)
</div>
</div>
</div>
</template>
script
section below you can find the implementation of the mouseEvent
method. Note that we are supplying a source
argument to this method to help us differentiate whether the call is originating from the parent wrapper or an actual menu item. This is necessary to prevent a phenomenon known as event bubbling, in which two events are triggered when a mouse event is set for two elements in a parent-child structure. Calling the stopPropagation
method prevents cascading of further events.NavBar
component is responsible for showing and hiding the MegaMenu
component. This is easy to handle and it is taken care of by the mouseEvent
method to toggle the showMegaMenu
data variable. However, we still need to know when the user has moved the mouse outside the mega menu to hide it as well. To achieve this, we need a way to send a signal from one component to another. This is where the idea of an event bus comes into the picture. Vue has a special feature for emitting custom events. The only prerequisite for that is a common Vue instance that both components can refer to. Create a JavaScript file named eventBus.js
and import it in the script section of both components.// eventBus.js
import Vue from "vue";
const eventBus = new Vue();
export default eventBus;
$on
and $off
custom event methods in the mounted
and beforeDestroy
lifecycle hooks respectively. In our example, a hide-mega-menu
event will set the showMegaMenu
to false
when triggered.<script>
import MegaMenu from "./MegaMenu";
import eventBus from "../eventBus";
export default {
name: "NavBar",
components: {
MegaMenu,
},
data() {
return {
menuItems: [
{ key: "products", label: "Products" },
{ key: "solutions", label: "Solutions" },
{ key: "pricing", label: "Pricing" },
{ key: "case-studies", label: "Case Studies" },
{ key: "blog", label: "Blog" },
{ key: "contact", label: "Contact" },
],
showMegaMenu: false,
};
},
methods: {
mouseEvent(event, source, key = "") {
if (source === "item") {
event.stopPropagation();
}
this.showMegaMenu = key === "products";
},
},
mounted() {
eventBus.$on("hide-mega-menu", () => {
this.showMegaMenu = false;
});
},
beforeDestroy() {
eventBus.$off("hide-mega-menu");
},
};
</script>
template
section of the MegaMenu
component is made up of two side-by-side div
s representing a vertical list of main categories on the left and a square grid of sub-categories with images on the right. We’re using a grid
display to achieve a ratio of 1 to 3 for these two div
s. There is a handy online tool for generating CSS grids that comes with an intuitive interface and visualization to quickly configure your desired layout.v-if
directive for simplicity, but in a real-life project this would be connected to an API to retrieve the actual product categories. If you’re going that route, I recommend making your API calls using Vue’s state management pattern known as Vuex.mouseover
event to identify the active main category. In this case, however, we’re using Vue’s dynamic class binding (:class
) to toggle an active
class on the main category item. This couldn’t be accomplished using CSS’s :hover
selector, as we wouldn’t be able to keep the main category highlighted after the user has moved the mouse to select a sub-category. Sub-categories themselves are still using the :hover
CSS selector.<template>
<div @mouseleave="hideMegaMenu()" class="megamenu-wrapper">
<div class="main-categories">
<div
v-for="index in 8"
:key="index"
@mouseover="activeMainCategory = index"
:class="['main-category', isActive(index) ? 'active' : '']"
>
<div class="icon"><img src="../assets/main-category.svg" /></div>
<div class="label">Main category {{ index }}</div>
</div>
</div>
<div class="sub-categories">
<div v-for="index in 15" :key="index" class="sub-category">
<div class="icon"><img src="../assets/sub-category.svg" /></div>
<div class="label">
Sub-category {{ activeMainCategory }}/{{ index }}
</div>
</div>
</div>
</div>
</template>
script
section is rather simple. It imports the eventBus
and emits the hide-mega-menu
event whenever the mouse leaves the mega menu, so that the NavBar
component could hide it. The active main category is determined by storing its index number in a data variable called activeMainCategory
.<script>
import eventBus from "../eventBus";
export default {
name: "MegaMenu",
data() {
return {
activeMainCategory: 1,
};
},
methods: {
hideMegaMenu() {
eventBus.$emit("hide-mega-menu");
},
isActive(key) {
return this.activeMainCategory === key;
},
},
};
</script>
atan2
function does, albeit in radians. We can then compare this angle to a constant value to determine whether the cursor is moving vertically or horizontally, thus preventing unwanted category selections.MegaMenu
component. First, we need to listen to mousemove
events in order to get the latest position of the cursor. Create a new method named mouseDirection
and bind it to the mousemove
event in the mounted
and beforeDestroy
lifecycle hooks.<script>
import eventBus from "../eventBus";
export default {
name: "MegaMenu",
data() {
return {
activeMainCategory: 1,
};
},
methods: {
hideMegaMenu() {
eventBus.$emit("hide-mega-menu");
},
isActive(key) {
return this.activeMainCategory === key;
},
mouseDirection(e) {
console.log(e.pageX, e.pageY);
},
},
mounted() {
window.addEventListener("mousemove", this.mouseDirection);
},
beforeDestroy() {
window.removeEventListener("mousemove", this.mouseDirection);
},
};
</script>
lastX
and lastY
to hold the last known cursor coordinates, as well as direction
to indicate whether the mouse is travelling vertically or horizontally. We also need to create a method named changeMainCategory
which only changes activeMainCategory
when direction
is vertical
. This method will replace the previous binding of mouseover
for each main category.theta
to the mouseDirection
method and set direction
to vertical
if theta
is larger than 75 degrees. Otherwise, it should be horizontal
. Update values of lastX
and lastY
at the end of the method.<script>
import eventBus from "../eventBus";
export default {
name: "MegaMenu",
data() {
return {
activeMainCategory: 1,
lastX: 0,
lastY: 0,
direction: "",
};
},
methods: {
hideMegaMenu() {
eventBus.$emit("hide-mega-menu");
},
isActive(key) {
return this.activeMainCategory === key;
},
mouseDirection(e) {
let theta = Math.abs(
(180 * Math.atan2(e.pageY - this.lastY, e.pageX - this.lastX)) / Math.PI
);
this.direction = theta > 75 ? "vertical" : "horizontal";
this.lastX = e.pageX;
this.lastY = e.pageY;
},
changeMainCategory(index) {
console.log(this.direction);
if (this.direction === "vertical") {
this.activeMainCategory = index;
}
},
},
mounted() {
window.addEventListener("mousemove", this.mouseDirection);
},
beforeDestroy() {
window.removeEventListener("mousemove", this.mouseDirection);
},
};
</script>
mousemove
event is very sensitive and captures every little cursor movement. Moreover, it is unlikely for the user to move in a perfect vertical direction,. Therefore, calculating angle theta
too often would result in some inaccuracies.theta
, so the solution is to throttle the mouseDirection
method. Create a new data variable named throttle
and set its default value to false
. Add an if-statement to mouseDirection
to only proceed if this value is false
. Once through, we should set throttle
to true
and add a setTimeout
to disable throttling after a fixed amount of time, such as 50 milliseconds.<script>
import eventBus from "../eventBus";
export default {
name: "MegaMenu",
data() {
return {
activeMainCategory: 1,
lastX: 0,
lastY: 0,
direction: "",
throttle: false,
};
},
methods: {
hideMegaMenu() {
eventBus.$emit("hide-mega-menu");
},
isActive(key) {
return this.activeMainCategory === key;
},
mouseDirection(e) {
if (!this.throttle) {
this.throttle = true;
let theta = Math.abs(
(180 * Math.atan2(e.pageY - this.lastY, e.pageX - this.lastX)) /
Math.PI
);
this.direction = theta > 75 ? "vertical" : "horizontal";
this.lastX = e.pageX;
this.lastY = e.pageY;
setTimeout(() => {
this.throttle = false;
}, 50);
}
},
changeMainCategory(index) {
if (this.direction === "vertical") {
this.activeMainCategory = index;
}
},
},
mounted() {
window.addEventListener("mousemove", this.mouseDirection);
},
beforeDestroy() {
window.removeEventListener("mousemove", this.mouseDirection);
},
};
</script>