DEV Community

Cover image for Building a VueJS dropdown menu component
mmmrks
mmmrks

Posted on

Building a VueJS dropdown menu component

Just for fun — let's build a reusable dropdown menu component with VueJS. You can check out the working demo here.

If you just want to use the component, you can find it on npm or github

 

Let's build the thing 🚀

We're assuming that you have a basic understanding of how the VueJS and VueJS single file components (SFC) work and that you already have a VueJS project running

1. Create a file called src/components/vue-dropdown-menu.vue and add following basic SFC structure:

<template>

</template>

<script>
  export default {

  }
</script>

<style lang="scss" scoped>

</style>
Enter fullscreen mode Exit fullscreen mode

As you can see — just a basic SFC structure here — nothing magical.

 

2. Add the following HTML markup to the <template> part of the SFC structure

<template>

  <section class="dropDownMenuWrapper">

    <button class="dropDownMenuButton">

    </button>

    <div class="iconWrapper">
      <div class="bar1" />
      <div class="bar2" />
      <div class="bar3" />
    </div>

    <section class="dropdownMenu">
      <div class="menuArrow" />
      <slot/>
    </section>

  </section>

</template>
Enter fullscreen mode Exit fullscreen mode

⬆️ What's happening here:

.dropDownMenuWrapper
An element that will wrap our component

.dropDownMenuButton
A button that will actually open & close our menu

.iconWrapper ( And the .bar elements )
Pure CSS icon that indicates if the menu is open or closed

.dropdownMenu
An element that will wrap the actual menu content —links and such.

.menuArrow
Just a for pointing purposes 😁

<slot/>
Content from the parent will be printed here

 

3. Add styles to the <style> part of the SFC structure

.dropDownMenuWrapper {
  position: relative;
  width: 500px;
  height: 80px;
  border-radius: 8px;
  background: white;
  border: 1px solid #eee;
  box-shadow: 10px 10px 0 0 rgba(black,.03);
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);

  * {
    box-sizing: border-box;
    text-align: left;
  }

  .dropDownMenuButton {
    border: none;
    font-size: inherit;
    background: none;
    outline: none;
    border-radius: 4px;
    position: absolute;
    top: 0;
    left: 0;
    display: flex;
    align-items: center;
    padding: 0 70px 0 20px;
    margin: 0;
    line-height: 1;
    width: 100%;
    height: 100%;
    z-index: 2;
    cursor: pointer;
  }

  .dropDownMenuButton--dark {
    color: #eee;
  }

  .iconWrapper {
    width: 25px;
    height: 25px;
    position: absolute;
    right: 30px;
    top: 50%;
    transform: translate(0,-50%);
    z-index: 1;

    .bar1 {
      width: 100%;
      max-width: 28px;
      height: 3px;
      background: blue;
      position: absolute;
      top: 50%;
      left: 50%;
      border-radius: 9999px;
      transform: translate(-50%, calc(-50% - 8px) );
      transition: all 0.2s ease;
    }

    .bar1--dark {
      background: #eee;
    }

    .bar1--open {
      transform: translate(-50%, -50%) rotate(45deg);
      margin-top: 0;
      background: red;
    }

    .bar2 {
      width: 100%;
      max-width: 28px;
      height: 3px;
      background: blue;
      position: absolute;
      top: 50%;
      left: 50%;
      border-radius: 9999px;
      opacity: 1;
      transform: translate(-50%, -50%);
      transition: all 0.2s ease;
    }

    .bar2--dark {
      background: #eee;
    }

    .bar2--open {
      opacity: 0;
    }

    .bar3 {
      width: 100%;
      max-width: 28px;
      height: 3px;
      background: blue;
      position: absolute;
      top: 50%;
      left: 50%;
      border-radius: 9999px;
      transform: translate(-50%, calc(-50% + 8px) );
      transition: all 0.2s ease;
    }

    .bar3--dark {
      background: #eee;
    }

    .bar3--open {
      top: 50%;
      transform: translate(-50%, -50% ) rotate(-45deg);
      background: red;
    }

  }

  .iconWrapper--noTitle {
    left: 0;
    top: 0;
    bottom: 0;
    right: 0;
    width: auto;
    height: auto;
    transform: none;
  }

  .dropdownMenu {
    position: absolute;
    top: 100%;
    width: 100%;
    min-width: 300px;
    min-height: 10px;
    border-radius: 8px;
    border: 1px solid #eee;
    box-shadow: 10px 10px 0 0 rgba(black,.03);
    background: white;
    padding: 10px 30px;
    animation: menu 0.3s ease forwards;

    .menuArrow {
      width: 20px;
      height: 20px;
      position: absolute;
      top: -10px;
      left: 20px;
      border-left: 1px solid #eee;
      border-top: 1px solid #eee;
      background: white;
      transform: rotate(45deg);
      border-radius: 4px 0 0 0;
    }

    .menuArrow--dark {
      background: #333;
      border: none;
    }

    .option {
      width: 100%;
      border-bottom: 1px solid #eee;
      padding: 20px 0;
      cursor: pointer;
      position: relative;
      z-index: 2;

      &:last-child {
        border-bottom: 0;
      }

      * {
        color: inherit;
        text-decoration: none;
        background: none;
        border: 0;
        padding: 0;
        outline: none;
        cursor: pointer;
      }

    }

    .desc {
      opacity: 0.5;
      display: block;
      width: 100%;
      font-size: 14px;
      margin: 3px 0 0 0;
      cursor: default;
    }

  }

  .dropdownMenu--dark {
    background: #333;
    border: none;

    .option {
      border-bottom: 1px solid #888;
    }

    * {
      color: #eee;
    }

  }

  @keyframes menu {
    from { transform: translate3d( 0, 30px ,0 ) }
    to { transform: translate3d( 0, 20px ,0 ) }
  }

}

.dropDownMenuWrapper--noTitle {
  padding: 0;
  width: 60px;
  height: 60px;
}

.dropDownMenuWrapper--dark {
  background: #333;
  border: none;
}
Enter fullscreen mode Exit fullscreen mode

Pretty basic styling — We are not going thru all these — as you can style your component anyway you like.

 

4. Add some function to our component.
Previously we added the .dropDownMenuButton -button to the template, and now we are going expand that element to actually do something. Modify the element as follows:

<button class="dropDownMenuButton" ref="menu" @click="openClose">{{menuTitle}}</button>
Enter fullscreen mode Exit fullscreen mode

⬆️ What's happening here:

  1. We added the @click="openClose" which will fire the method openClose when we click the button.
  2. We added the ref="menu" that refers to the element — we need this later on.
  3. We added the template tag {{menuTitle}} that will show us our menu title.

 

— then, let's create the openClose method to control the opening and closing the menu. So modify the <script> part of the structure like this:

export default {
  props: [ "menuTitle" ], // Menu title from the parent
  data() {
    return {
      isOpen: false // Variable if the menu is open or closed
  },
  methods: {

    openClose() {

      // Toggle between open or closed ( true || false )
      isOpen = !isOpen

    }

  }
}
Enter fullscreen mode Exit fullscreen mode

⬆️ What's happening here:

We added the openClose method to toggle isOpen variable between true and false — we also added the menuTitle prop so we can pass our menus title from the parent.

— to make things actually work, we need to add the isOpen variable to the template:

Modify the .bar1 & .bar2 & .bar3 elements as follows:

<div class="bar1" :class="{ 'bar1--open' : isOpen }" />
<div class="bar2" :class="{ 'bar2--open' : isOpen }" />
<div class="bar3" :class="{ 'bar3--open' : isOpen }" />
Enter fullscreen mode Exit fullscreen mode

Also modify the .dropdownMenu as follows:

<section class="dropdownMenu" v-if="isOpen" >
      <div class="menuArrow" />
      <slot/>
</section>
Enter fullscreen mode Exit fullscreen mode

⬆️ What's happening here:

We added the :class="{ 'bar1--open' : isOpen }" to the bar -elements — we toggle classes based on the value of isOpen so we can get that nice icon animation that you can see in the demo.

In the .dropdownMenu -element we added the v-if="isOpen" part — if isOpen is true show the menu and vice versa.

Congrats 🏆

You now have a working component! BUT... We can make it even better. For the UI/UX purposes — we should add a function that closes the menu if the user clicks anywhere else on the document. To add that, we have to expand the openClose method and add a new method called catchOutsideClick.

First let's expand the openClose method, modify the method to look like this:

openClose() { var _this = this

  const closeListerner = (e) => {

    if ( _this.catchOutsideClick(e, _this.$refs.menu ) )
      window.removeEventListener('click', closeListerner) , _this.isOpen = false

   }

   window.addEventListener('click', closeListerner)

   this.isOpen = !this.isOpen

},
Enter fullscreen mode Exit fullscreen mode

 
— then we need to add a new method called catchOutsideClick;

catchOutsideClick(event, dropdown)  {

  // When user clicks menu — do nothing
  if( dropdown == event.target )
    return false

  // When user clicks outside of the menu — close the menu
  if( this.isOpen && dropdown != event.target )
    return true

}
Enter fullscreen mode Exit fullscreen mode

⬆️ What's happening here:

We added an eventListener to catch all click events — when we catch one, we pass the event and the clicked element to catchOutsideClick method which will then check if the click is on the menu or outside of it. If the menu is open and the click was outside the menu — we will remove the eventListener and close the menu.

Bonus 🎉

You might have noticed earlier — that we have a bunch of --dark classes in the styles. That's because we want our component to support a dark mode if the user prefers that.

So to make those styles work we are adding a bit more code to our component.

First, let's make our template to look like this:

<section class="dropDownMenuWrapper" :class="{ 'dropDownMenuWrapper--dark' : isDarkMode, 'dropDownMenuWrapper--noTitle' : !menuTitle }">

  <button class="dropDownMenuButton" ref="menu" @click="openClose" :class="{ 'dropDownMenuButton--dark' : isDarkMode }">
      {{ menuTitle }}
  </button>

  <div class="iconWrapper" :class="{ 'iconWrapper--noTitle' : !menuTitle }">
    <div class="bar1" :class="{ 'bar1--open' : isOpen , 'bar1--dark' : isDarkMode }" />
    <div class="bar2" :class="{ 'bar2--open' : isOpen , 'bar2--dark' : isDarkMode }" />
    <div class="bar3" :class="{ 'bar3--open' : isOpen , 'bar3--dark' : isDarkMode }" />
  </div>

  <section class="dropdownMenu" v-if="isOpen" :class="{ 'dropdownMenu--dark' : isDarkMode }">
    <div class="menuArrow" :class="{ 'menuArrow--dark' : isDarkMode }" />
    <slot/>
  </section>

</section>
Enter fullscreen mode Exit fullscreen mode

 
Second, add new variable called isDarkMode and prop called darkMode:

props: [ "darkMode", "menuTitle" ],
data() {
  return {
    isOpen: false,
    isDarkMode: false
  }
}
Enter fullscreen mode Exit fullscreen mode

 
Third, add watcher to watch darkMode prop:

watch: {
  darkMode(val) {

    // Force dark mode
    if( !val )
      this.isDarkMode = false

    // Force dark mode
    if( val == 'force' )
      this.isDarkMode = true

    // Switch dark / light mode automatically according to what user prefer
    if( val == 'auto' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches )
        this.isDarkMode = true

  }
}
Enter fullscreen mode Exit fullscreen mode

Whats happening here ⬆️:

We added a new prop and variable to indicate what style we want to use — we also added conditional classes to all HTML elements — so that if isDarkMode is true we add a special --dark class to elements and lastly we added a watcher to change the mode accordingly.

darkMode prop accepts three kinds of values:
false → Always show light mode
force → Always show dark mode
auto → Automatically change according to what user prefs

You can find the whole code for the component here

How to use

  1. Include the component
  2. Use it
<dropdown-menu menu-title="Vue Dropdown Menu" dark-mode="auto">

  <section class="option">
    <button @click="sayHello">This is button for method</button>
    <span class="desc">This is Vue dropdown menu method that says hello for you.</span>
  </section>

  <section class="option">
   <a href="https://duckduckgo.com">This is basic a -link</a>
   <span class="desc">Clicking this takes you somewhere else.</span>
  </section>

  <section class="option">
    <router-link to="/about">This is Vue router link</router-link>
    <span class="desc">Clicking this takes you somewhere else.</span>
  </section>

</dropdown-menu>
Enter fullscreen mode Exit fullscreen mode

 

🎉✌️🙏

 

Top comments (0)