Do you ever start up your favorite application, click an action button, and then boom, there's a popup from the application about your interaction?
For example, you might click the "delete" button and then be greeted by a "Are you sure you'd like to delete the file?" pop-up.
These are called "modals" and, despite the anguish of many developers, they're widely used as a method to grab a user's attention in applications of all kinds.
You may be surprised to learn that they can be challenging to implement despite their ubiquity.
However, you may not be surprised to learn that these modals are so common that there's an API in React, Angular, and Vue that makes modals easier to implement, an API that's almost exclusively for these kinds of modal components.
What is this API called? Portals.
Why do we need a dedicated API for this use case? CSS.
The Problem with Modals; CSS Stacking Contexts
Let's build the "Delete file" modal we saw in our framework of choice:
React
Angular
Vue
const Modal = () => { return ( <div> <div class="modal-container"> <h1 class="title">Are you sure you want to delete that file?</h1> <p class="body-text"> Deleting this file is a permanent action. You’re unable to recover this file at a later date. Are you sure you want to delete this file? </p> <div class="buttons-container"> <button class="cancel">Cancel</button> <button class="confirm">Confirm</button> </div> </div> </div> );};
@Component({ selector: "delete-modal", changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div> <div class="modal-container"> <h1 class="title">Are you sure you want to delete that file?</h1> <p class="body-text"> Deleting this file is a permanent action. You’re unable to recover this file at a later date. Are you sure you want to delete this file? </p> <div class="buttons-container"> <button class="cancel">Cancel</button> <button class="confirm">Confirm</button> </div> </div> </div> `,})class ModalComponent {}
<!-- Modal.vue --><template> <div> <div class="modal-container"> <h1 class="title">Are you sure you want to delete that file?</h1> <p class="body-text"> Deleting this file is a permanent action. You’re unable to recover this file at a later date. Are you sure you want to delete this file? </p> <div class="buttons-container"> <button class="cancel">Cancel</button> <button class="confirm">Confirm</button> </div> </div> </div></template>
Now that we have that modal, let's build a small version of the folder app we've been building in this book. This version of the app should showcase the modal, the header, and a copyright footer:
Imagine you have a set of components that represent the small app we just built:
In this component layout, the Modal was showing under the Footer component. The reason this was happening is that the Modal is trapped under a "CSS Stacking Context".
Let's simplify the chart and see what I mean;
Here, we can see that despite Modal being assigned a z-index of 99, it's trapped under the Header, which is a z-index of 1. The Modal cannot escape this encapsulated z-index painting order, and as a result, the Footer shows up on top.
Ideally, to solve this problem, we'd want to move Modal to be in our HTML after the Footer, like so:
But how can we do this without moving the Modal component outside the Header component?
This is where JavaScript portals come into play. Portals allow you to render the HTML for a component in a different location of the DOM tree than the location of our component tree.
This is to say that your framework components will be laid out like the tree on the left but will render out like the flat structure on the right.
Let's take a look at how we can build these portals ourselves.
Using Local Portals
While it's not the most useful example of using a portal, let's see how we can use a portal to teleport part of a UI to another part of the same component:
React
Angular
Vue
While most of React's APIs can be imported directly from react, the ability to create portals actually comes from the react-dom package.
Once imported, we can use ReactDOM.createPortal to render JSX into an HTML element.
You'll notice that we're then displaying the return of createPortal - portal - within the component. This allows the portal to be activated, which will place the Hello world! inside of the div.
While the other frameworks have something akin to a portal system built into their frameworks' core, Angular does not. Instead, the Angular team maintains a library called the "Angular CDK" to have shared UI code for utilities such as portals.
Before we can talk about the Angular CDK, however, we have to talk about an Angular feature called "templates".
Explaining ng-template
An ng-template allows you to store multiple tags as children without rendering them. You can then take those tags and render them in special ways in the future using Angular APIs.
Correct! By default, an ng-template will not render anything at all.
So then what's the point?
The point is that we can use the ng-template as a sort of "template" for rendering content in the future. We can assign the ng-template to a template variable, and then render that variable in the future.
<ng-template #tag> This template is now assigned to the "tag" template variable.</ng-template>
This variable can then be passed to a number of Angular APIs to render the contents of the ng-template in a special way. Think of <ng-template> as a sort of "template" for rendering content in the future.
Using Angular CDK Portals
Now that we understand ng-template, we can talk about the Angular CDK.
To use the Angular CDK, you'll first need to install it into your project:
npm i @angular/cdk
From here, we can import components and utilities directly from the CDK.
You'll notice that we're creating a variable called domPortal that we assign an instance of DomPortal. This DomPortal instance allows us to take a captured reference to some HTML (in this case, a div with Hello world!), and project it elsewhere.
This domPortal is then assigned to a [cdkPortalOutlet] input. This input is automatically created on all ng-templates when PortalModule is imported.
If you forget to import PortalModule, you'll see an error like so:
Can't bind to 'cdkPortalOutlet' since it isn't a known property of 'ng-template' (used in the 'AppComponent' component template).
This cdkPortalOutlet is where the captured HTML is then projected into.
Rendering ng-template
Because we're using a div to act as the parent element of the portal's contents, there might be a flash of the div on screen before our afterRenderEffect occurs. This flash happens because a div is an HTML element that renders its contents on the screen, and then our afterRenderEffect goes back and removes the div from the DOM.
As such, we may want to use an ng-template, which does not render to the DOM in the first place:
Vue may have the most minimal portal API of them all: You use the built-in Teleport component and tell it which HTML element you want it to render to using the to input.
It's worth mentioning that this is not the most useful example of a portal, because if we are within the same component, we could simply move the elements around freely, with full control over a component.
Now that we know how to apply portals within a component, let's see how we can apply a portal to be at the root of the entire application.
Application-Wide Portals
In local portals, we were able to see that implementations of portals rely on an element reference to be set to a variable. This tells us where we should render our portal's contents.
While this worked, it didn't do much to solve the original issue that portals were set out to solve; overlapping stacking contexts.
If there was a way that we could provide a variable to all the application's components, then we could have a way to solve the stacking context problem within our apps...
Once again, Vue's straightforward API approach is visible through the pairing of its provide API, which hosts a variable of the location to present portals into, and its Teleport API, which enables the portal's usage.
Our portals should be able to render over all the other content we draw within our apps now!
HTML-Wide Portals
If you only use React, Angular, or Vue in your apps, you can fairly safely use application-wide portals without any significant hiccups... But most applications don't just use React, Angular, or Vue.
Consider the following scenario:
You're tasked with implementing a chat overlay system on your marketing website. This overlay system aims to help users get in touch with a customer support rep when they get stuck in a pinch.
They want the UI to look something like this:
While you could build this out yourself, it's often costly to do so. Not only do you have to build out your own chat UI, but you also have to build out the backend login system for your customer reps to use, the server communication between them, and more.
Luckily for you, it just so happens that a service called "UnicornChat" solves this exact problem!
UnicornChat doesn't exist, but many other services exist like it. Any reference you see to "UnicornChat" in this article is purely fictional, but based on real companies that exist to solve this problem. The APIs I'll demonstrate are often very similar to what these companies really offer.
UnicornChat integrates with your app by adding a script tag to your HTML's head tag:
<!-- This is an example and does not really work --><script src="https://example.com/unicorn-chat.min.js"></script>
It handles everything else for you! It will add a button to the end of your <body> tag, like so:
<body> <div id="app"><!-- Your React app here --></div> <div id="unicorn-chat-contents"><!-- UnicornChat UI here --></div></body>
This is awesome, and it solved your ticket immediately... or so you thought.
When QA goes to test your app, they come back with a brand-new bug you've never seen before; The UnicornChat UI draws on top of your file deletion confirmation dialog.
This is because the contents of your React app are rendered before the UnicornChat UI since the UnicornChat code is in a div that's after your React's container div.
How can we solve this? By placing our portal's contents in the body itself after the UnicornChat UI.
React
Angular
Vue
Using the second argument of createPortal, we can pass a reference to the HTML body element by simply using a querySelector.
We'll then wrap that querySelector into a useMemo so that we know not to re-fetch that reference again after it is grabbed once.
function ChildComponent() { const bodyEl = useMemo(() => { return document.querySelector("body"); }, []); return createPortal(<div>Hello, world!</div>, bodyEl);}function App() { return ( <> {/* Even though it's rendered first, it shows up last because it's being appended to `<body>` */} <ChildComponent /> <div style={{ height: "100px", width: "100px", border: "2px solid black" }} /> </> );}
An embedded webpage:React HTML-Wide Portals - StackBlitz
To use a portal that attaches directly to body in Angular, we need to switch from using a cdkPortalOutlet to manually attaching and detaching a portal to a DomPortalOutlet.
We can reuse our existing global service to create one of these DomPortalOutlets and attach and detach it in our modal component, like so:
import { TemplatePortal, DomPortalOutlet } from "@angular/cdk/portal";@Injectable({ providedIn: "root",})class PortalService { outlet = new DomPortalOutlet(document.querySelector("body")!);}@Component({ selector: "modal-comp", changeDetection: ChangeDetectionStrategy.OnPush, template: ` <ng-template #portalContent>Hello, world!</ng-template> `,})class ModalComponent { portalContent = viewChild.required("portalContent", { read: TemplateRef }); viewContainerRef = inject(ViewContainerRef); portalService = inject(PortalService); constructor() { afterRenderEffect((onCleanup) => { this.portalService.outlet.attach( new TemplatePortal(this.portalContent(), this.viewContainerRef), ); onCleanup(() => { this.portalService.outlet.detach(); }); }); }}@Component({ selector: "app-root", imports: [ModalComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <!-- Even though it's rendered first, it shows up last because it's being appended to <body> --> <modal-comp /> <div style="height: 100px; width: 100px; border: 2px solid black;"></div> `,})class AppComponent {}
An embedded webpage:Angular HTML-Wide Portals - StackBlitz
<!-- App.vue --><script setup>import Child from "./Child.vue";</script><template> <!-- Even though it's rendered first, it shows up last because it's being appended to <body> --> <Child /> <div style="height: 100px; width: 100px; border: 2px solid black"></div></template>
An embedded webpage:Vue HTML-Wide Portals - StackBlitz
The code we wrote previously for this challenge worked well, but it had a major flaw; it would not show up above other elements with a higher z-index in the stacking context.
React
Angular
Vue
An embedded webpage:React Portals Pre-Challenge - StackBlitz