Create modals with Vue3 Teleport + TailwindCSS
Tags: vue, tutorial, tailwindcss, css
Published: 2021-02-10 21:57:28

Vue 3 brought us a lot of amazing new features, but one of my favorites still is the `Teleport`.

Why? because the `<teleport />` tag allows you to move elements from one place to another in a Vue application. Think of it as a portal to move between dimensions 🦄:

![portals](https://media.giphy.com/media/1DuPWdQKQOS2Y/source.gif)

Actually, it was called like this in earlier stages of Vue 3 but eventually, the Vue Core team decided to change it.

Vue normally encourages building UIs by encapsulating UI related behaviors scoped inside components. However, sometimes makes sense that certain part of the component template to live somewhere else in the DOM.

A perfect example of this is a full-screen modal, it is a common scenario that we want to keep the modal's logic to live within the component (closing the modal, clicking an action) but we want to place it "physically" somewhere else, like at `body` level without having to recur to tricky CSS.

In this tutorial, we're going to cover step by step how to implement a modal dialog with this feature and styling it with my favorite utility framework [TailwindCSS](https://tailwindcss.com/) along with: 

- Slots
- Composition API


However, I will assume that you already have a certain level on Vue because I will not cover any basics.

If you prefer to check this tutorial in a video, here is it: 

{% youtube Qbo54gBiKTg %}

## Prerequisites

Before starting, scaffold a simple Vue3 app with your preferred method ([vue-cli](https://cli.vuejs.org/guide/creating-a-project.html), [Vite](https://vitejs.dev/guide/#scaffolding-your-first-vite-project)).

In my case, I will create it using Vite ⚡️ by running: 

```bash
yarn create @vitejs/app modals-n-portals --template vue
```

Afterward, install TailwindCSS

```bash
npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
```

In case you run into trouble, you may need to use the PostCSS 7 compatibility build instead. You can check the process [here](https://tailwindcss.com/docs/installation#post-css-7-compatibility-build)

Next, generate your `tailwind.config.js` and `postcss.config.js` files with:

```bash
npx tailwindcss init -p
```
To finish add the following into your main `css` file in the project

```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

Remember to import the `css` file into your `main.js`.

Now we're ready to start.

# What is Teleport

![Goku Teleport](https://i.gifer.com/QjMQ.gif)

Is a wrapper component `<teleport />` that the user can render a piece of a component in a **different place in the DOM tree**, even if this place is not in your app's or component's scope.

It takes a `to` attribute that specifies where in the DOM you want to teleport an element to. This destination must be somewhere outside the component tree to avoid any kind of interference with other application’s UI components. 

```html
<teleport to="body">
  <!-- Whatever you want to teleport -->
</teleport>
```

# Create the Modal component

Create a `ModalDialog.vue` inside of the `components` directory and start filling the template

```vue
<template>
  <teleport to="body">
    <div
        class="w-1/2 bg-white rounded-lg text-left overflow-hidden shadow-xl"
        role="dialog"
        ref="modal"
        aria-modal="true"
        aria-labelledby="modal-headline"
    >
       Awiwi
    </div>
  </teleport>
</template>

<script>
...
```

So we include an element with `role="dialog"` inside the `<teleport to="body">` which will send our modal to the main body.

From the style perspective,`w-1/2` will set the width of the modal to a *50%* `bg-white rounded-lg` will give us a nice white rounded dialog and `shadow-xl` will give it a little bit of depth.

Now add this component to your `App.vue`

```vue
<template>
  <ModalDialog />
</template>

<script>
import ModalDialog from './components/ModalDialog.vue';

const components = {
  ModalDialog,
};
export default {
  name: 'App',
  components,
};
</script>

<style></style>
```

![Screenshot 2021-02-09 at 20.28.44](https://dev-to-uploads.s3.amazonaws.com/i/al5ly4olqp7evtfsasb8.png)
 
Well, that doesn't look very much like a modal (yet), but the desired outcome is there, if you look closer to the DOM in the inspector, the `ModalDialog` template has been "teleported" to the very end of the body tag (with the green background) even if it's logic was defined inside the App (with the yellow background)

## Make it look like a modal

![Screenshot 2021-02-09 at 20.26.55](https://dev-to-uploads.s3.amazonaws.com/i/68vcvxf6by0rq4h3pzjz.png)
 
Logic is in place, now let's make it pretty.

At the moment we just have a `div` element that works as the modal, but to achieve the correct UX we need to place it on top of a full-screen, fixed backdrop with blackish reduced opacity. The modal also needs to be centered horizontally and have a proper position (around 25% to 50% from the top of the browser)

This is pretty simple to achieve with some wrappers and TailwindCSS magic, to our current component template, surround our modal element with the following:

```vue
<template>
  <teleport to="body">
    <div
      ref="modal-backdrop"
      class="fixed z-10 inset-0 overflow-y-auto bg-black bg-opacity-50"
    >
      <div
        class="flex items-start justify-center min-h-screen pt-24 text-center"
      >
        <div
          class="bg-white rounded-lg text-left overflow-hidden shadow-xl p-8 w-1/2"
          role="dialog"
          ref="modal"
          aria-modal="true"
          aria-labelledby="modal-headline"
        >
          Awiwi
        </div>
      </div>
    </div>
  </teleport>
</template>

```
The `modal-backdrop` will `fix` our component's position relative to the browser window and the child div containing the `flex` class will handle the centering and padding from the top. Now, our modal should look something like this:

![Screenshot 2021-02-10 at 07.49.47](https://dev-to-uploads.s3.amazonaws.com/i/tnpa5q8935lnb8mlpe13.png)

Ok, now it's more likely 😛.

# Adding props to the Modal

Of course, we wouldn't like a Modal that sticks visible all the time over or web/app content, so let's add some logic to make it toggleable.

```vue
<script>
const props = {
  show: {
    type: Boolean,
    default: false,
  },
};
export default {
  name: 'ModalDialog',
  props,
  setup() {
    // Code goes here
  }
};
</script>
```

Since it's considered bad practice to modify props directly and we do want to toggle our modal from inside the component (clicking a close button or clicking outside of the modal to close it) we should declare a variable using `ref` to show the modal inside the `setup` method and update it whenever the prop changes using `watch`

```javascript
import { ref, watch } from 'vue';

setup(props) {
  const showModal = ref(false);

  watch(
    () => props.show,
    show => {
      showModal.value = show;
    },
  );

  return {
    showModal,
  };
},
```

Right after, add a `v-if="showModal"` to the `div[ref="modal-backdrop"]`.
 
Jump on your `App.vue` and create a Button for toggling the modal. In case you're lazy, just copy this snippet 😜

```vue
<template>
  <div class="page p-8">
    <button
      type="button"
      @click="showModal = !showModal"
      class="mx-auto w-full flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
    >
      Open Modal
    </button>
    <ModalDialog :show="showModal" />
  </div>
</template>

<script>
import ModalDialog from './components/ModalDialog.vue';
import { ref } from 'vue';

const components = {
  ModalDialog,
};
export default {
  name: 'App',
  components,
  setup() {
    const showModal = ref(false);
    return {
      showModal,
    };
  },
};
</script>
```
![Open Modal](https://dev-to-uploads.s3.amazonaws.com/i/c9vbk70g636g4fmhtg1d.gif)
 
## Animate it

Now that we have our modal working (kinda), you're probably triggered by the fact that the element appears just like that, without any transition or animation.

To smooth things up, let's combine Vue's  `<transition />` wrapper with the magic of TailwindCSS.

First, surround the `modal-backdrop` with the following code:

```html
 <transition
      enter-active-class="transition ease-out duration-200 transform"
      enter-from-class="opacity-0"
      enter-to-class="opacity-100"
      leave-active-class="transition ease-in duration-200 transform"
      leave-from-class="opacity-100"
      leave-to-class="opacity-0">
  <div
     ref="modal-backdrop"
     class="fixed z-10 inset-0 overflow-y-auto bg-black bg-opacity-50"
     v-show="showModal">
     ...
  </div>
</transition>
```

These classes will add a smooth opacity fade In effect to the backdrop, notice that we also changed the `v-if` for `v-show`.

Do the same for the `modal` but this time, we will apply different classes to achieve a more elegant transition using translation and scaling.

```html
<transition
  enter-active-class="transition ease-out duration-300 transform "
  enter-from-class="opacity-0 translate-y-10 scale-95"
  enter-to-class="opacity-100 translate-y-0 scale-100"
  leave-active-class="ease-in duration-200"
  leave-from-class="opacity-100 translate-y-0 scale-100"
  leave-to-class="opacity-0 translate-y-10 translate-y-0 scale-95"
>
  <div
    class="bg-white rounded-lg text-left overflow-hidden shadow-xl p-8 w-1/2"
    role="dialog"
    ref="modal"
    aria-modal="true"
    v-show="showModal"
    aria-labelledby="modal-headline"
  >
    Awiwi
  </div>
</transition>
```

![ezgif.com-gif-maker (2)](https://dev-to-uploads.s3.amazonaws.com/i/yqnhvz51ii8ce5v0klph.gif)

🤤 🤤 🤤 🤤 

## Using slots for the modal content

Now that our modal works like charm, let's add the possibility to pass the content through Vue [slots](https://v3.vuejs.org/guide/component-slots.html).

```html
<div
    class="bg-white rounded-lg text-left overflow-hidden shadow-xl p-8 w-1/2"
    role="dialog"
    ref="modal"
    aria-modal="true"
    v-show="showModal"
    aria-labelledby="modal-headline"
>
    <slot>I'm empty inside</slot>
</div>
```

So now we can pass anything we want from the parent component using our `ModalDialog` component: 

```html
<ModalDialog :show="showModal">
    <p class="mb-4">Gokuu is...</p>
    <img src="https://i.gifer.com/QjMQ.gif" />
</ModalDialog>
```

Voilá

![ezgif.com-gif-maker (3)](https://dev-to-uploads.s3.amazonaws.com/i/wjmtp1u4des8qd31ebay.gif)
 

## Close logic

To this point maybe the article is getting too long, but it worth it, I promise, so stick with me we're only missing one step. 

Let's add some closure (Pi dun tsss), now seriously inside of the `modal` let's had a flat button with a close icon inside.

If you don't want to complicate yourselves with Fonts/SVGs or icon components, if you are using Vite ⚡️, there is this awesome [plugin](https://github.com/antfu/vite-plugin-icons) based on [Iconify](https://github.com/iconify/iconify) you can use, it's ridiculously easy.

Install the plugin and peer dependency @iconify/json

```bash
npm i -D vite-plugin-icons @iconify/json
```
Add it to vite.config.js

```javascript
// vite.config.js
import Vue from '@vitejs/plugin-vue'
import Icons from 'vite-plugin-icons'

export default {
  plugins: [
    Vue(),
    Icons()
  ],
}
```

So back to where we were:

```vue
<template>
  ...
  <div
    class="relative bg-white rounded-lg text-left overflow-hidden shadow-xl p-8 w-1/2"
    role="dialog"
    ref="modal"
    aria-modal="true"
    v-show="showModal"
    aria-labelledby="modal-headline"
  >
    <button class="absolute top-4 right-4">
      <icon-close @click="closeModal" />
    </button>
    <slot>I'm empty inside</slot>
  </div>
  ...
</template>

<script>
  import { ref, watch } from "vue";

  import IconClose from "/@vite-icons/mdi/close.vue";
  const props = {
    show: {
      type: Boolean,
      default: false,
    },
  };
  export default {
    name: "ModalDialog",
    props,
    components,
    setup(props) {
      const showModal = ref(false);

      function closeModal() {
        showModal.value = false;
      }

      watch(
        () => props.show,
        (show) => {
          showModal.value = show;
        }
      );

      return {
        closeModal,
        showModal,
      };
    },
  };
</script>

```
The circle is finally complete.


## Bonus

In case you got this far, I got a little bonus for you, let's use the composition API to close our `ModalDialog` whenever we click outside (on the backdrop).

Create a file under `src/composables/useClickOutside.js` with  the following code, 😅 trust me, it works even if looks like Chinese:

```javascript
// Same implementation as https://github.com/vueuse/vueuse/blob/main/packages/core/onClickOutside/index.ts

import { watch, unref, onUnmounted } from 'vue';

const EVENTS = ['mousedown', 'touchstart', 'pointerdown'];

function unrefElement(elRef) {
  return unref(elRef)?.$el ?? unref(elRef);
}

function useEventListener(...args) {
  let target;
  let event;
  let listener;
  let options;

  [target, event, listener, options] = args;

  if (!target) return;

  let cleanup = () => {};

  watch(
    () => unref(target),
    el => {
      cleanup();
      if (!el) return;

      el.addEventListener(event, listener, options);

      cleanup = () => {
        el.removeEventListener(event, listener, options);
        cleanup = noop;
      };
    },
    { immediate: true },
  );

  onUnmounted(stop);

  return stop;
}

export default function useClickOutside() {
  function onClickOutside(target, callback) {
    const listener = event => {
      const el = unrefElement(target);
      if (!el) return;

      if (el === event.target || event.composedPath().includes(el)) return;

      callback(event);
    };

    let disposables = EVENTS.map(event =>
      useEventListener(window, event, listener, { passive: true }),
    );

    const stop = () => {
      disposables.forEach(stop => stop());
      disposables = [];
    };

    onUnmounted(stop);

    return stop;
  }
  return {
    onClickOutside,
  };
}

```

All you need to know is how to use this `composable` function, so in our `ModalDialogComponent` add the following code on the setup method:

```
setup(props) {
    ...
    const modal = ref(null);
    const { onClickOutside } = useClickOutside();

    ...

    onClickOutside(modal, () => {
        if (showModal.value === true) {
        closeModal();
        }
    });

    return {
        ...
        modal,
    };
}
```

Using template ref (on `div[ref="modal"`) we essentially pass the target element and a callback to close the modal. The composition function adds event listeners to the window (*mousedown*, *touchstart*, *pointerdown*) which essentially controls if you clicked on the target (modal) element or not

Congratulations, you now have the latest state of the art modal using Vue3 Teleport and TailwindCSS

{% github alvarosaburido/alvaro-dev-labs- %}

As always, feel free to contact me in the comments section. Happy to answer. Cheers 🍻