DEV Community

Cover image for Create modals with Vue3 Teleport + TailwindCSS
Alvaro Saburido
Alvaro Saburido

Posted on • Updated on

Create modals with Vue3 Teleport + TailwindCSS

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

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

Prerequisites

Before starting, scaffold a simple Vue3 app with your preferred method (vue-cli, Vite).

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

yarn create @vitejs/app modals-n-portals --template vue
Enter fullscreen mode Exit fullscreen mode

Afterward, install TailwindCSS

npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
Enter fullscreen mode Exit fullscreen mode

In case you run into trouble, you may need to use the PostCSS 7 compatibility build instead. You can check the process here

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

npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

To finish add the following into your main css file in the project

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

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

Now we're ready to start.

What is Teleport

Goku Teleport

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.

<teleport to="body">
  <!-- Whatever you want to teleport -->
</teleport>
Enter fullscreen mode Exit fullscreen mode

Create the Modal component

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

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

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

<template>
  <ModalDialog />
</template>

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

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

<style></style>
Enter fullscreen mode Exit fullscreen mode

Screenshot 2021-02-09 at 20.28.44

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

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:

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

Enter fullscreen mode Exit fullscreen mode

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

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.

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

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

import { ref, watch } from 'vue';

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

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

  return {
    showModal,
  };
},
Enter fullscreen mode Exit fullscreen mode

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 😜

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

Open Modal

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:

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

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.

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

ezgif.com-gif-maker (2)

🤤 🤤 🤤 🤤

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.

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

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

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

Voilá

ezgif.com-gif-maker (3)

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 based on Iconify you can use, it's ridiculously easy.

Install the plugin and peer dependency @iconify/json

npm i -D vite-plugin-icons @iconify/json
Enter fullscreen mode Exit fullscreen mode

Add it to vite.config.js

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

export default {
  plugins: [
    Vue(),
    Icons()
  ],
}
Enter fullscreen mode Exit fullscreen mode

So back to where we were:

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

Enter fullscreen mode Exit fullscreen mode

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:

// 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,
  };
}

Enter fullscreen mode Exit fullscreen mode

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

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 logo alvarosaburido / alvaro-dev-labs-

Alvaro Dev Labs ⚡️

repository-banner.png

Alvaro Dev Labs ⚡️

Playing ground for my articles and my YouTube Channel

Install

yarn
Enter fullscreen mode Exit fullscreen mode

Usage

Branch names has the same (or similar) title as the articles and youtube videos.

yarn dev

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

Discussion (4)

Collapse
atinybeardedman profile image
Sean Dickinson

I'm wondering if this could be simplified a bit. For instance we not use v-model to control the state of the modal and just emit an event when it should be closed? I seem to have it working fairly well this way.

Also, the clickOutside composition I'm sure has much better accessibility, but as a quick and dirty solution I usually add a @click.prevent to the div that has the modal content and then an actual click listener on the close icon and the backdrop to close the modal.

Collapse
egdiala profile image
egdiala

I'm having issues adding classes to my component after creating it with Vue3's teleport. The classes don't take effect even though it shows whenever I inspect element.

Collapse
egdiala profile image
egdiala

Found the issue. Apparently I was working with scoped styles.

Collapse
smarques profile image
sergio marchesini

Thank you for this. One question: what is the noop var in useClickOutside.js ?
What does the line
cleanup = noop;
do?

TIA