Introduction
In this short tutorial I will show you how to build a simple modal and make it reusable by utilizing Vue’s Teleport component and some other neat tricks.
Follow along on YouTube:
Link to code: GitHub
What is Teleport?
<Teleport>
is a really cool built-in component introduced in Vue 3. It allows us to "teleport" a part of a component's template into a DOM node that exists outside the DOM hierarchy of that component. What this means is we can render components anywhere on the DOM tree, even if the component lies deeply nested somewhere in the DOM.
The most common example of needing such functionality are elements that sit on top of the app such as modals, popups and notifications. Such elements would be much easier to handle if they were rendered entirely separate from the DOM of our Vue app. This is because handling events, playing around with position and z-index styles can be very hard when having a modal or popup that has many parents it needs to account for.
Setup
I have created a new Vue project using Vite with the vue-ts
template.
npm create vite@latest modal-tutorial -- --template vue-ts
I have also installed Tailwindcss in order to style the components. For more information about Vite + Tailwind go here.
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Creating our Modal
Let’s create a simple Confirm Dialog modal. We will use Tailwindcss to style it:
Create ModalConfirm.vue
:
<template>
<div
class="absolute inset-0 overflow-y-auto bg-black bg-opacity-50"
>
<div
class="flex items-start justify-center min-h-screen mt-24 text-center"
>
<div
class="bg-white text-black rounded-lg text-center shadow-xl p-6 w-64"
role="dialog"
aria-modal="true"
>
<h3>Do you confirm?</h3>
<div class="flex justify-center py-4 text-white">
<!-- We will handle these emits later -->
<button @click="$emit('close')" class="border border-black bg-white text-black mr-4">No</button>
<button @click="$emit('close')">Yes</button>
</div>
</div>
</div>
</div>
</template>
And import it to the App.vue:
<script setup lang="ts">
import {ref} from "vue";
import ModalConfirm from './components/ModalConfirm.vue';
const show = ref(false);
const openConfirm = () => {
show.value = true;
}
const closeConfirm = () => {
show.value = false;
}
</script>
<template>
<div class="flex justify-center items-center min-h-screen">
<ModalConfirm v-if="show" @close="closeConfirm"></ModalConfirm>
<button @click="openConfirm">Open Confirm Modal</button>
</div>
</template>
<style scoped>
</style>
As you can see, here we have some logic about opening and closing the Modal. We also handle the emit from the ModalConfirm component. The modal should look like this:
Breaking our Modal
Currently our modal should be displaying just fine even if it is nested. But let’s try to break it:
<script setup lang="ts">
import {ref} from "vue";
import ModalConfirm from './components/ModalConfirm.vue';
const show = ref(false);
const openConfirm = () => {
show.value = true;
}
const closeConfirm = () => {
show.value = false;
}
</script>
<template>
<div class="flex justify-center items-center min-h-screen">
<ModalConfirm v-if="show" @close="closeConfirm"></ModalConfirm>
<button @click="openConfirm">Open Confirm Modal</button>
<!-- This absolute element will break our modal -->
<div class="absolute top-20 bg-red-500 p-2 w-full left-0">This will make things ugly</div>
</div>
</template>
<style scoped>
</style>
We have added a new element with absolute positioning. This is a good example of how hard it can be have your modal nested, because you need to think whether it will conflict with another element.
Let’s have a look at what is happening in the DOM:
As you can see, our modal is rendered on the same level as our “This will make things ugly” element. How much better would it be if the modal could be rendered in another place? Introducing (drum roll…) <Teleport>
.
Adding Teleport
Let’s go to our index.html
and add a new element with an id=modal
. This way our modal will be rendered outside of our Vue app.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<!-- Where our Vue app is mounted -->
<div id="app"></div>
<!-- Where our modal will appear -->
<div id="modal"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Let’s now go back to our App.vue
:
<script setup lang="ts">
import {ref} from "vue";
import ModalConfirm from './components/ModalConfirm.vue';
const show = ref(false);
const openConfirm = () => {
show.value = true;
}
const closeConfirm = () => {
show.value = false;
}
</script>
<template>
<div class="flex justify-center items-center min-h-screen">
<!-- Will render the content in element with id=modal -->
<Teleport to="#modal">
<ModalConfirm v-if="show" @close="closeConfirm"></ModalConfirm>
</Teleport>
<button @click="openConfirm">Open Confirm Modal</button>
<!-- This absolute element will break our modal -->
<div class="absolute top-20 bg-red-500 p-2 w-full left-0">This will make things ugly</div>
</div>
</template>
<style scoped>
</style>
Using Vue 3’s <Teleport>
component we can render the modal outiside the Vue DOM. The component accepts a prop to
which is a CSS selector. Here we specify #modal
which corresponds to the <div id="modal">
in our index.html
page.
If we open our modal again we should see this:
Let’s see how it looks like in the DOM:
Great! Now our modal’s logic is in our Vue app but the modal itself is rendered outside of it and doesn’t clash with any other elements. We fixed it. Next step - let’s make it better.
Creating a Composable
Let’s create a composable that will keep of the logic necessary to display the modal. This is a great way to make our code reusable.
Let’s create a composables
folder in our project and create a new file called useModal.ts
:
import { ref } from 'vue'
// keep track of component to render
const component = ref();
// keep track of whether to show modal
const show = ref(false);
export function useModal() {
return {
component,
show,
// methods to show/hide modal
showModal: () => show.value = true,
hideModal: () => show.value = false,
}
}
This is a really simple composable which keeps track of two refs and returns two methods. We are keeping track of component in case in the feature we want to render different modals. 👀
show
is keeping track of whether we show the modal or not and the two methods allow us to open and close the modal.
Now, let’s go back to App.vue
:
<script setup lang="ts">
// import the composable
import { useModal } from './composables/useModal';
// use this to parse the component
import { markRaw } from 'vue';
import ModalConfirm from './components/ModalConfirm.vue';
// initialize modal
const modal = useModal();
// set the modal component to the ModalConfirm component and open the modal
const openConfirm = () => {
modal.component.value = markRaw(ModalConfirm);
modal.showModal();
};
</script>
<template>
<div class="flex justify-center items-center min-h-screen">
<Teleport to="#modal">
<ModalConfirm v-if="modal.show.value" @close="modal.hideModal"/>
</Teleport>
<button @click="openConfirm">Open Confirm Modal</button>
</div>
<div class="absolute top-20 bg-red-500 p-2 w-full left-0">This will make things ugly</div>
</template>
<style scoped>
</style>
We use the new composable to switch our logic and then replace all occurences to use the data from the modal composable. We use the openConfirm
method to assign the ModalConfirm
component to the modal. In order to do this we use the markRaw
helper. Read more.
If we test it now, it should still be working the same, but our logic will be extracted to this composable and will be ready to be reused.
Adding Dynamic Component
Okay, so we have our ModalConfirm
component. But what if we have more than one modal, maybe we want to dynamically display different modals based on our use case. Remember, we added component
in our useModal
composable? We can use this together with Vue’s Dynamic Component feature to display different modals.
Let’s start by creating our second modal. It will be very similar to the first one. We will just use for the purposes of this tutorial. Let’s create ModalOverview.vue
:
<template>
<div
class="absolute inset-0 bg-black bg-opacity-50"
>
<div
class="flex items-start justify-center min-h-screen mt-24 text-center"
>
<div
class="bg-white text-black rounded-lg text-center shadow-xl p-6 w-64"
role="dialog"
aria-modal="true"
>
<h2 class="text-lg font-bold">Overview of items:</h2>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
<li>Item 4</li>
<li>Item 5</li>
</ul>
<div class="flex justify-center py-4 text-white">
<button @click="$emit('close')">Close</button>
</div>
</div>
</div>
</div>
</template>
As you can see it is very similar to the first one with minor differences.
Now that we have our second modal, let’s go back to App.vue
:
<script setup lang="ts">
import { useModal } from './composables/useModal';
import { markRaw } from 'vue';
import ModalConfirm from './components/ModalConfirm.vue';
import ModalOverview from './components/ModalOverview.vue';
const modal = useModal();
const openConfirm = () => {
modal.component.value = markRaw(ModalConfirm);
modal.showModal();
};
const openOverview = () => {
modal.component.value = markRaw(ModalOverview);
modal.showModal();
};
</script>
<template>
<div class="flex justify-center items-center min-h-screen">
<Teleport to="#modal">
<!-- Dynamic Vue Component based on our useModal component value -->
<component :is="modal.component.value" v-if="modal.show.value" @close="modal.hideModal"/>
</Teleport>
<button @click="openConfirm">Open Confirm Modal</button>
<span class="mx-4"></span>
<button @click="openOverview">Open Overview Modal</button>
</div>
<div class="absolute top-20 bg-red-500 p-2 w-full left-0">This will make things ugly</div>
</template>
<style scoped>
</style>
As you can see, we import the ModalOverview
component and add a second button that will assign it to the composable and open the modal. The most important part is we use <component :is="">
which is a Vue dynamic component. The is
prop accepts either the name of the component we want to render or the component itself. In our case we set it to use the component
from our useModal
composable. The second modal should look like this now:
Adding Transition
Lastly, on our task list is to add a transition so that the modal appears nicely animated on the screen. We can use yet another Vue 3 component - <Transition>
:
In App.vue
:
<script setup lang="ts">
import { useModal } from './composables/useModal';
import { markRaw } from 'vue';
import ModalConfirm from './components/ModalConfirm.vue';
import ModalOverview from './components/ModalOverview.vue';
const modal = useModal();
const openConfirm = () => {
modal.component.value = markRaw(ModalConfirm);
modal.showModal();
};
const openOverview = () => {
modal.component.value = markRaw(ModalOverview);
modal.showModal();
};
</script>
<template>
<div class="flex justify-center items-center min-h-screen">
<Teleport to="#modal">
<Transition>
<component :is="modal.component.value" v-if="modal.show.value" @close="modal.hideModal"/>
</Transition>
</Teleport>
<button @click="openConfirm">Open Confirm Modal</button>
<span class="mx-4"></span>
<button @click="openOverview">Open Overview Modal</button>
</div>
<div class="absolute top-20 bg-red-500 p-2 w-full left-0">This will make things ugly</div>
</template>
<style scoped>
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>
We also need to add a few CSS classes in our <style>
. Below you will find a diagram explaining these styles:
With that done, the modal is now complete. 🎉
Conclusion
During this tutorial, we managed to learn more about the Vue Teleport component, as well as the Vue Transition component. We used best practices and managed to extract some of our logic into a Composable. Lastly, we also learned about Vue Dynamic components, enabling us to render multiple types of modals.
Useful Resources
💚 If you want to learn more about Vue and the Vue ecosystem make sure to follow me on my socials. I create Vue content every week and am slowly starting to gain traction so I’d really appreciate your help!
Latest comments (2)
Impressive! I must say, this is quite remarkable bro!
Glad you liked it!