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.
- 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) - 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:
- 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. - We added a
toggleOpen()
method that will simple invert the state of thatopen
state. - We added
v-if="open" @click="open = false"
to ourfixed 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) - 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 ourButtonWithDropdown.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:
- 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
) - We listen for clicks to our image, and toggle the dropdown using that context:
@click="context.toggleOpen"
- 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)
Finally, we pass bind :open and :toggleOpen to our
To our what James?! To our what?!
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. 🤦)Much clearer, thanks for the update!
Hi, you have to add transform close to scale.