DEV Community

Cover image for How to create a dropdown with TailwindCSS and Vue
James Baldwin
James Baldwin

Posted on • Originally published at jwbaldwin.com

How to create a dropdown with TailwindCSS and Vue

Create custom dropdowns with TailwindCSS and Vue

This post was originally posted on my personal blog at jwbaldwin.com

I'm going to assume you already have Vue and TailwindCSS set up, but if you don't here is a great resource: github.com/tailwindcss/setup-examples

Here are the versions of Vue and TailwindCSS that I'm using:

Vue: 2.6.10
TailwindCSS: 1.2.0

All the code for this can be found on my github at github.com/jwbaldwin and in the codesandbox below!

Alright, let's get right into it.

First: The Setup

We'll have two main components for this. The Vue component that will act as the dropdown, and the Vue component which will open the dropdown when clicked.

The dropdown component will be pretty straight forward:

//MainDropdown.vue
<template>
    <div>
        <div>
            <div></div> <- Where our functionality will go
            <slot></slot> <- Where we will put the dropdown items
        </div>
    </div>
</template>

<script>
export default {
    data() {
        return { <- Where we will track our modal state (open/closed)
        };
    },
    methods: { <- Where we will toggle the state
    },
};
</script>

Okay! Nothing fancy going on here. A little Vue slot api usage, so that we can reuse this component for dropdowns all throughout the app! Basically, we're going to define what we want rendered in that slot in another component.

So, let's scaffold the items we'll display!

//ButtonWithDropdown.vue
<template>
  <main-dropdown>
    <template> <- Where we will say "hey vue, put this in the slot"
      <img src="../assets/profile.png" alt="profile image">
      <div> <- What we want displayed in the dropdown
        <ul>
          <li>
            <a to="/profile">
              <div>{{ username }}</div>
              <div>{{ email }}</div>
            </a>
          </li>
          <li>
            <a to="/profile">Profile</a>
          </li>
          <li>
            <a>Sign out</a>
          </li>
        </ul>
      </div>
    </template>
  </main-dropdown>
</template>

<script>
import MainDropdown from "@/components/MainDropdown";

export default {
  name: "button-with-dropdown",
  data() {
    return {
      username: "John Wick",
      email: "dontkillmydog@johnwick.com"
    };
  },
  components: { MainDropdown }
};
</script>

Great, so it looks terrible and doesn't work. Let's fix the style with TailwindCSS.

Next: The Styling

//MainDropdown.vue
<template>
  <div class="flex justify-center">
    <div class="relative">
      <div class="fixed inset-0"></div>
      <slot></slot>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {};
  },
  methods: {}
};
</script>

The div element with fixed inset-0 will cover the entire page. Just remember this little guy. More on what it does later!

We're going to make sure the parent is "relative" so that we can position the child dropdown absolute in relation to that element. And then we apply some other positioning so that it sits where we want it to!

//ButtonWithDropdown.vue
<template>
    <main-dropdown>
        <template>
            <img class="h-10 w-10 cursor-pointer rounded-full border-2 border-gray-400 object-cover" src="../assets/profile.png" alt="profile image">
            <transition 
             enter-active-class="transition-all duration-100 ease-out" 
             leave-active-class="transition-all duration-100 ease-in" 
             enter-class="opacity-0 scale-75"
             enter-to-class="opacity-100 scale-100"
             leave-class="opacity-100 scale-100"
             leave-to-class="opacity-0 scale-75">
                <div class="origin-top-right absolute right-0 mt-2 w-64 bg-white border overflow-hidden rounded-lg shadow-md">
                    <ul>
                        <li>
                            <a to="/profile" class="rounded-t-lg block px-4 py-3 hover:bg-gray-100">
                                <div class="font-semibold ">{{ username }}</div>
                                <div class="text-gray-700">{{ email }}</div>
                            </a>
                        </li>
                        <li class="hover:bg-gray-100">
                            <a class="font-semibold block px-4 py-3" to="/profile">Profile</a>
                        </li>
                        <li class="hover:bg-gray-100">
                            <a class="font-semibold block px-4 py-3" to="/profile">Sign Out</a>
                        </li>
                    </ul>
                </div>
...
</script>

There's a bit more going on here. Most of it is just styling, but we are adding a couple of things I want to point out.

  1. We are using the transition element provided by Vue and then combining that with TailwindCSS classes to make the dropdown fade in and out! (when it actually opens and closes)
  2. We have some hover: pseudo-class variants that apply styles based on if an element is hovered or not.

Alright! It's really coming along. Not half-bad, but let's make it work!

Finally: The Functionality

The key interaction here:

The MainDropdown.vue component, that we slot the button into, will allow the ButtonWithDropdown.vue component to access it's context and call methods provided by MainDropdown.vue.

Let's see how that works!

//MainDropdown.vue
<template>
    <div class="flex justify-center">
        <div class="relative">
            <div v-if="open" @click="open = false" class="fixed inset-0"></div>
            <slot :open="open" :toggleOpen="toggleOpen"></slot>
        </div>
    </div>
</template>

<script>
export default {
    data() {
        return {
            open: false,
        };
    },
    methods: {
        toggleOpen() {
            this.open = !this.open;
        },
    },
};
</script>

Okay so let's go over what we did here:

  1. We added a boolean open: false to our component data. This will determine if we show the dropdown (and our "fixed inset-0" element) or not.
  2. We added a toggleOpen() method that will simple invert the state of that open state.
  3. We added v-if="open" @click="open = false" to our fixed inset-0 element. Remember how I said this element will cover the whole page? Right, so now it only shows when our dropdown is open, so if we click anywhere outside of the dropdown...boom! The dropdown closes as you'd expect! (told ya I'd explain that, not magic anymore)
  4. Finally, we bind :open and :toggleOpen to our 'slot'. Whatever get's "slotted" into this component, can now access :open and :toggleOpen as props. In our case, that's our ButtonWithDropdown.vue. We'll see how in the next snippet!

Okay, the final touches!

//ButtonWithDropdown.vue
<template>
    <main-dropdown>
        <template slot-scope="context">
            <img @click="context.toggleOpen" class="h-10 w-10 cursor-pointer rounded-full border-2 border-gray-400 object-cover" src="../assets/profile.png" alt="profile image">
            <transition enter-active-class="transition-all duration-100 ease-out" leave-active-class="transition-all duration-100 ease-in" enter-class="opacity-0 scale-75"
                enter-to-class="opacity-100 scale-100" leave-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-75">
                <div v-if="context.open" class="origin-top-right absolute right-0 mt-2 w-64 bg-white border overflow-hidden rounded-lg shadow-md">
                    <ul>
                        <li>
...

Only three things to note here:

  1. We tell our component that we can access the scope by using the variable context (slot-scope="context"). Now we have full access to those props we just bound (:open, :toggleOpen)
  2. We listen for clicks to our image, and toggle the dropdown using that context: @click="context.toggleOpen"
  3. Finally, we hide the dropdown elements: v-if="context.open"

THAT'S IT!

You now have a fully functioning dropdown in Vue, with styling courtesy of TailwindCSS!

Here is a codesandbox with the full example!

Fin

The full working example (with each step as a branch) can be found in my github.com/jwbaldwin

If you liked this and want to see more stuff like it, feel free to follow me on twitter @jwbaldwin_ or head over to my blog where I share these posts :)

Thanks!

Top comments (4)

Collapse
 
victorioberra profile image
Victorio Berra

Finally, we pass bind :open and :toggleOpen to our

To our what James?! To our what?!

Collapse
 
jw_baldwin profile image
James Baldwin • Edited

Fair point! I updated that to hopefully explain what's going there. There are more details in the next snippet as well :)

This is a use of scoped slots (Vue 2.6+), and it allows the slot content simple access to data that only the child component has. (vuejs.org/v2/guide/components-slot...)

Hope that helps!

(P.S. Realized now, that the <slot> I wrote there got rendered away... But leaving this in case someone actually wants the information. 🤦)

Collapse
 
victorioberra profile image
Victorio Berra

Much clearer, thanks for the update!

Collapse
 
ramyou profile image
Ramzi Youssef

Hi, you have to add transform close to scale.