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 🦄:
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
Afterward, install TailwindCSS
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
Next, generate your tailwind.config.js
and postcss.config.js
files with:
npx tailwindcss init -p
To finish add the following into your main css
file in the project
@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
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>
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>
...
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>
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
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>
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:
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>
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,
};
},
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>
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>
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>
🤤 🤤 🤤 🤤
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>
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>
Voilá
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
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()
],
}
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>
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,
};
}
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
alvarosabu / alvaro-dev-labs-
Alvaro Dev Labs ⚡️
Alvaro Dev Labs ⚡️
Playing ground for my articles and my YouTube Channel
Install
yarn
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 🍻
Top comments (8)
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.
Thanks for this tutorial, greatfull for it!
There is one problem though, when you click the second time on the button to open the modal after it had been closed (in modal), it do not open (need to click it one more time).
I changed showModal.value TO true in the ModalDialog. Do not know if it is the optimised way to do it, but I did not get any errors. Looks funny though, if you console log; props.show, as it says true when opened (console log in watch) and true when closed (console.log in closeModal()) Other suggestions of course welcomed!
The code changed:
time)watch(
() => props.show, show => {showModal.value = true;
}
);.
EDIT
I come up with an easier solution, just put the close button under App slot part , alter the value like the main button for the modal do , and you can then get rid of the cloesModal function in ModalDialog. Set;
() => props.show, show => {showModal.value = show;
(as in article code)
/
Klas
Thank you for this. One question: what is the noop var in useClickOutside.js ?
What does the line
cleanup = noop;
do?
TIA
in many frameworks noop is a shorthand for
() => {}
. It usually stands for "no operation".I see thank you!
Thanks a lot for this tutorial!
I was wondering if there is a way to have more than one instance of this modal in a view?
I have an admin panel and I'd like to use this modal twice within the same view, so far I haven't been able to do so, it always opens one of the modals.
Any help will be much appreciated!
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.
Found the issue. Apparently I was working with scoped styles.