DEV Community

Cover image for Creating better Modals using Vue Teleport
Alexander Gekov
Alexander Gekov

Posted on • Updated on

Creating better Modals using Vue Teleport

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

Modal

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>
Enter fullscreen mode Exit fullscreen mode

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.

Broken Modal

Let’s have a look at what is happening in the DOM:

Bad 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

Fixed Modal

Let’s see how it looks like in the DOM:

Better 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,
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

Second Modal

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>
Enter fullscreen mode Exit fullscreen mode

We also need to add a few CSS classes in our <style>. Below you will find a diagram explaining these styles:

Transition

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!

Twitter

LinkedIn

YouTube

Top comments (2)

Collapse
 
jet4419 profile image
Dev Jet

Impressive! I must say, this is quite remarkable bro!

Collapse
 
alexandergekov profile image
Alexander Gekov

Glad you liked it!