PortalVue is a set of two components that allow you to render a component’s template (or a part of it) anywhere in the document - even outside the part controlled by your Vue App!
Alternative: VueSimplePortal
PortalVue
offers quite a lot of functionality allowing for some creative use cases. However, for the most common use case (render .e.g a modal at the end of<body>
), most of that isn’t required.
So if you are just looking for a solution to that one problem, have a look at my new second portal project,
@linusborg/vue-simple-portal
Portal is a well-known concept from React that was also adopted in Vue 2 through third-party plugins like portal-vue. Its name suggests that it’s responsible for “teleporting” something from one to another place… and this is exactly what it does!
With a portal, you can render a component in a different place in the DOM tree, even if this place is not in your app’s scope. Portals are very handy when working with modals, notifications, popups or other elements that are sensitive to where they’re placed in the DOM tree.
Let me show you
<!-- UserCard.vue -->
<template>
<div class="user-card">
<b> {{ user.name }} </b>
<button @click="isPopUpOpen = true">Remove user</button>
<div v-show="isPopUpOpen">
<p>Are you sure?</p>
<button @click="removeUser">Yes</button>
<button @click="isPopUpOpen = false">No</button>
</div>
</div>
</template>
In the above code example we have a UserCard
component that lets us remove a certain user from the database. After clicking the button, we will see a confirmation popup where we can confirm the action and remove the user with the removeUser
method.
Keeping related components (the confirmation popup) in the same place is a good practice in terms of code maintenance. But when it comes to UI elements that should appear on top of the others, it can lead to some problems.
The first problem that we can encounter is the fact that user-card
class, as well as every other class higher in the DOM hierarchy, can affect the appearance of our popup. For example, if any of the containers have visibility: 0.5
, the popup’s visibility will be affected.
Guaranteeing that our popup will appear on top of other components is another challenge. You can think about DOM elements as layers. We put those layers on top of each other to make a layout. Usually, when we want to cover some of the layers with other ones, we do this intentionally by either putting another element inside this layer or after it.
One way of dealing with this issue is to use z-index
CSS property to change the natural order of an element’s appearance. However, this method is not very elegant and usually gives us another challenge to deal with when we have multiple elements positioned with z-index
This is why we usually place UI elements that should appear on top of the other ones just before the closing </body>
tag. This way we don’t need to do any hacks to be sure that our popup shows exactly where and how we want. It also ensures that other elements don’t cover it.
So it looks like we have two conflicting good practices:
UserCard
componentbody
tag.To fulfill both requirements, we need to make sure that even though the popup code is located in the UserCard
component, it will render somewhere else - ideally just before the closing body
tag.
Install Package:
npm install --save portal-vue
# or with yarn
yarn add portal-vue
Add it to your application:
import PortalVue from 'portal-vue'
Vue.use(PortalVue)
For more detailed installation instructions, additional options and installation via CDN, see the Installation section of the documentation.
About the examples
The following examples contain live demos. When looking at them, keep in mind that for demo purposes, we move content around within one component, however in reality the
<portal-target>
can be positioned anywhere in your App.
Also, the code of the Examples uses the Single-File-Component Format (“
.vue
” files). If you’re not familiar with this, check out the official docs here.
<portal to="destination">
<p>This slot content will be rendered wherever the
<portal-target> with name 'destination'
is located.
</p>
</portal>
<portal-target name="destination">
<!--
This component can be located anwhere in your App.
The slot content of the above portal component will be rendered here.
-->
</portal-target>
Example
The content below this paragraph is rendered in the right/bottom (red) container by PortalVue
This is content from the left/top container (green). The cool part is, it works across components, so you can send your content anywhere!
<template>
<Design-Container>
<Design-Panel color="green" text="Source">
<p>
The content below this paragraph is
rendered in the right/bottom (red) container by PortalVue
</p>
<Portal to="right-basic">
<p class="red">
This is content from the left/top container (green).
The cool part is, it works across components,
so you can send your content anywhere!
</p>
</Portal>
</Design-Panel>
<Design-Panel color="red" text="Target" left>
<PortalTarget name="right-basic"></PortalTarget>
</Design-Panel>
</Design-Container>
</template>
<portal to="destination" :disabled="true">
<p>
This slot content will be rendered right here as long as the `disabled` prop
evaluates to `true`,<br />
and will be rendered at the defined destination as when it is set to `false`
(which is the default).
</p>
</portal>
Example
The content below this paragraph is rendered in the right/bottom (red) container by PortalVue if the portal is enabled. Otherwise, it’s shown here in place.
This is content from the left/top container (green).
<template>
<div>
<button @click="disabled = !disabled">Toggle "Disable"</button>
<Design-Container>
<Design-Panel color="green" text="Source">
<p>
The content below this paragraph is
rendered in the right/bottom (red) container by PortalVue
if the portal is enabled. Otherwise, it's shown here in place.
</p>
<Portal to="right-disable" :disabled="disabled">
<p class="red">This is content from the left/top container (green).</p>
</Portal>
</Design-Panel>
<Design-Panel color="red" text="Target" left>
<PortalTarget name="right-disable"></PortalTarget>
</Design-Panel>
</Design-Container>
</div>
</template>
<script>
export default {
data: () => ({
disabled: false,
}),
}
</script>
<portal to="destination" v-if="usePortal">
<ul>
<li>
When 'usePortal' evaluates to 'true', the portal's slot content will be
rendered at the destination.
</li>
<li>
When it evaluates to 'false', the content will be removed from the
destination
</li>
</ul>
</portal>
Example
The content below this paragraph is rendered in the right/bottom (red) container by PortalVue if the portal is actually rendered. When it’s removed again, the content in the red panel will be removed as well.
This is content from the left/top container (green).
<template>
<div>
<button @click="show = !show">Toggle v-if</button>
{{show}}
<Design-Container>
<Design-Panel color="green" text="Source">
<p>
The content below this paragraph is
rendered in the right/bottom (red) container by PortalVue
if the portal is actually rendered.
When it's removed again, the content in the red panel will be removed as well.
</p>
<Portal v-if="show" to="right-conditional">
<p class="red">This is content from the left/top container (green).</p>
</Portal>
</Design-Panel>
<Design-Panel color="red" text="Target" left>
<PortalTarget name="right-conditional"></PortalTarget>
</Design-Panel>
</Design-Container>
</div>
</template>
<script>
export default {
data: () => ({
show: true,
}),
}
</script>
The <portal-target>
component has a multiple
mode, which allows to render content from multiple <portal>
components at the same time.
The order the content is rendered in can be adjusted through the order
prop on the <portal>
components:
<portal to="destination" :order="2">
<p>some content</p>
</portal>
<portal to="destination" :order="1">
<p>some other content</p>
</portal>
<portal-target name="destination" multiple />
Result
<div class="vue-portal-target">
<p>some other content</p>
<p>some content</p>
</div>
Live Example
This is content from the left/top container (green) #2.
This is content from the left/top container (green). #1
<template>
<div>
<Design-Container>
<Design-Panel color="green" text="Source #1">
<button @click="show1 = !show1">Toggle this</button>
<Portal :disabled="!show1" to="right-multiple" :order="2">
<p class="red">This is content from the left/top container (green). #1</p>
</Portal>
</Design-Panel>
<Design-Panel color="green" text="Source #2">
<button @click="show2 = !show2">Toggle this</button>
<Portal :disabled="!show2" to="right-multiple" :order="1">
<p class="red">This is content from the left/top container (green) #2.</p>
</Portal>
</Design-Panel>
<Design-Panel color="red" text="Target" left>
<PortalTarget name="right-multiple" multiple></PortalTarget>
</Design-Panel>
</Design-Container>
</div>
</template>
<script>
export default {
data: () => ({
show1: true,
show2: true,
}),
}
</script>
In older browsers, position: fixed
works unreliably when the element with that property is nested in a node tree that has other position
values.
But we normally need it to render components like modals, dialogs, notifications, snackbars and similar UI elements in a fixed position.
Also, z-indices can be a problem when trying to render things on top of each other somewhere in the DOM.
With PortalVue, you can render your modal/overlay/dropdown component to a <portal-target>
that you can position as the very last in the page’s body
, making styling and positioning much easier and less error-prone.
Now you can position your components with position: absolute
instead
<body>
<div id="app" style="position: relative;">
<div>
<portal to="notification-outlet">
<notification style="position: absolute; top: 20px; right: 20px;">
This overlay can be positioned absolutely very easily.
</notification>
</portal>
</div>
<!-- rest of your app -->
</div>
<portal-target name="notification-outlet"></portal-target>
</body>
If you use Vue for small bits and pieces on your website, but want to render something in a location at the other end of the page, PortalVue got you covered.
Among many other features, Vue 3 will come with native support for portals in the form of a Portal
component.
The good news is that the Portal
component is very simple! It has only one property - target
and a default slot. The slot content will render in the DOM element, that is selected by the query selector passed to the target
prop.
<!-- In some nested Vue component -->
<NestedComponent>
<Portal target="#popup-target">
<PopUp />
</Portal>
</NestedComponent>
<!-- before closing body tag -->
<div id="popup-target"></div>
In the above example, the PopUp
component will render in the div with an id of portal-target
, even though it’s positioned inside NestedComponent
.
Knowing this, we can rewrite our UserCard
component into this form:
<!-- UserCard.vue -->
<template>
<div class="user-card">
<b> {{ user.name }} </b>
<button @click="isPopUpOpen = true">Remove user</button>
<Portal target="#popup-target">
<div v-show="isPopUpOpen">
<p>Are you sure?</p>
<button @click="removeUser">Yes</button>
<button @click="isPopUpOpen = false">No</button>
</div>
</Portal>
</div>
</template>
Simple and easy, isn’t it? 😉 Now we can keep our code properly structured without being forced to do nasty workarounds to keep it working!
If you’re still curious and want to see other examples, here you can find a small website with a modal, using Vue 3 portals. You can also browse test scenarios on vue-next
repository.
#vue.js #vue #javascript #react