Creating better Modals using Vue Teleport
Tags: javascript, vue, tutorial, webdev
Published: 2023-02-28 08:37:26

## 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: 
{% embed https://youtu.be/GRpsfL4B0sE %}
Link to code: [GitHub](https://github.com/alexander-gekov/modal-tutorial)

## 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. 

```shell
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]([https://tailwindcss.com/docs/guides/vite](https://tailwindcss.com/docs/guides/vite)).

```shell
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`:
```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:

```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:

![Modal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u5fn8elv8h7deo90fzuu.png)

## Breaking our Modal

Currently our modal should be displaying just fine even if it is nested. But let’s try to break it:

```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>
				<!-- 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.

![Broken Modal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7w95avpxs0axu91tlpaa.png)

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

![Bad DOM](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rfa6467j31kumxndalt6.png)

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.

```html
<!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`:

```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:

![Fixed Modal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ut07mqpc3lwnw1nqotmj.png)

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

![Better DOM](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/aplqqnsj4gdt4dvlehtb.png)

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`:

```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`:

```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](https://vuejs.org/api/reactivity-advanced.html#markraw).
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`:

```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`:

```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:

![Second Modal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/enk9gjlfn3a66zvw5yxy.png)

## 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`:

```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:

![Transition](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p5dxl7hcq625td2r5v8u.png)

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

* [Vue Teleport](https://vuejs.org/guide/built-ins/teleport.html)

* [Vue Transition](https://vuejs.org/guide/built-ins/transition.html#css-based-transitions)

* [Vue markRaw](https://vuejs.org/api/reactivity-advanced.html#markraw)

* [Vue Dynamic Components](https://vuejs.org/guide/essentials/component-basics.html)

💚  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](https://twitter.com/AlexanderGekov)

[LinkedIn](https://www.linkedin.com/in/aleksandar-gekov/)

[YouTube](https://www.youtube.com/channel/UCAARceUUB_X6xsgeRBRg16A)