Portals

January 6, 2025

5,813 words

Post contents

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.

A popup that says, "Are you sure you want to delete that file?" with a "Confirm" and "Cancel" button

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:

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>	);};


CSS for the modal


.modal-container {	position: fixed;	top: 50%;	left: 50%;	transform: translateX(-50%) translateY(-50%);	padding: 20px 0px 0px;	background: #e0e3e5;	border-radius: 28px;	font-family: "Roboto", sans-serif;	color: #001f28;}.title {	margin: 0;	padding: 0px 24px 16px;	font-size: 24px;	font-weight: 400;}.body-text {	margin: 0;	padding: 0px 24px 24px;	font-size: 14px;}.buttons-container {	display: flex;	justify-content: flex-end;	padding: 16px;	gap: 8px;}.buttons-container button {	margin: 4px 0;	padding: 10px 24px;	border-radius: 1000px;	border: none;}.cancel {	background: #b8eaff;}.cancel:hover {	filter: brightness(0.8);}.cancel:active {	filter: brightness(0.6);}.confirm {	background: #2e6578;	color: white;}.confirm:hover {	filter: brightness(1.4);}.confirm:active {	filter: brightness(1.8);}

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:

A modal on top of a minimal version of the file app that includes a file list, header, and footer
const App = () => {	return (		<div>			<Header />			<Body />			<Footer />		</div>	);};
const Header = () => {	return (		<>			<div class="header-container">				<span class="icon-container">					<FolderIcon />				</span>				<span class="header-title">Main folder</span>				<span class="auto"></span>				<button class="icon-btn">					<DeleteIcon />				</button>			</div>		</>	);};
const Body = () => {	return (		<ul class="list-container">			{Array.from({ length: 10 }, (_, i) => (				<li class="list-item">					<FolderIcon />					<span>File number #{i + 1}</span>				</li>			))}		</ul>	);};
const Footer = () => {	return <div class="footer-container">Copyright 2022</div>;};
const DeleteIcon = () => {	return (		<svg viewBox="0 0 20 21">			<path d="M9 8V16H7.5L7 8H9Z" fill="currentColor" />			<path d="M12.5 16L13 8H11V16H12.5Z" fill="currentColor" />			<path				d="M8 0C7.56957 0 7.18743 0.27543 7.05132 0.683772L6.27924 3H1C0.447715 3 0 3.44772 0 4C0 4.55228 0.447715 5 1 5H2.56055L3.38474 18.1871C3.48356 19.7682 4.79471 21 6.3789 21H13.6211C15.2053 21 16.5164 19.7682 16.6153 18.1871L17.4395 5H19C19.5523 5 20 4.55228 20 4C20 3.44772 19.5523 3 19 3H13.7208L12.9487 0.683772C12.8126 0.27543 12.4304 0 12 0H8ZM12.9767 5C12.9921 5.00036 13.0076 5.00036 13.0231 5H15.4355L14.6192 18.0624C14.5862 18.5894 14.1492 19 13.6211 19H6.3789C5.85084 19 5.41379 18.5894 5.38085 18.0624L4.56445 5H6.97694C6.99244 5.00036 7.00792 5.00036 7.02334 5H12.9767ZM11.6126 3H8.38743L8.72076 2H11.2792L11.6126 3Z"				fill="currentColor"			/>		</svg>	);};
const FolderIcon = () => {	return (		<svg viewBox="0 0 20 16">			<path				d="M20 14C20 15.1046 19.1046 16 18 16H2C0.895431 16 0 15.1046 0 14V2C0 0.895431 0.89543 0 2 0H11C11.7403 0 12.3866 0.402199 12.7324 1H18C19.1046 1 20 1.89543 20 3V14ZM11 4V2H2V14H18V6H13C11.8954 6 11 5.10457 11 4ZM13 3V4H18V3H13Z"				fill="currentColor"			/>		</svg>	);};


CSS for the Rest of the App


body {	margin: 0;	padding: 0;}.header-container {	display: flex;	align-items: center;	gap: 0.5rem;	padding: 8px 12px;	border: 2px solid #f5f8ff;	background: white;	color: #1a42e6;	position: fixed;	top: 0;	left: 0;	width: 100%;	box-sizing: border-box;	z-index: 1;}.header-title {	font-family: "Roboto", sans-serif;	font-weight: bold;}.auto {	margin: 0 auto;}.icon-btn,.icon-container {	box-sizing: border-box;	background: none;	border: none;	color: #1a42e6;	border-radius: 0.5rem;	height: 24px;	width: 24px;	display: flex;	align-items: center;	justify-content: center;	padding: 4px;}.icon-btn svg {	width: 100%;}.icon-btn:hover {	background: rgba(26, 66, 229, 0.2);}.icon-btn:active {	background: rgba(26, 66, 229, 0.4);	color: white;}.list-container {	list-style: none;	display: flex;	flex-direction: column;	gap: 0.25rem;	margin: 0;	margin-top: 2.5rem;	padding: 1rem;}.list-item {	padding: 0.5rem 1rem;	display: flex;	align-items: center;	gap: 1rem;	color: #1a42e6;	font-family: "Roboto", sans-serif;	border-radius: 0.5rem;}.list-item:hover {	background: rgba(245, 248, 255, 1);}.list-item svg {	width: 24px;}.footer-container {	font-family: "Roboto", sans-serif;	position: relative;	z-index: 2;	background: white;	color: #1a42e6;	padding: 8px 12px;	border: 2px solid #f5f8ff;}

Awesome! This is looking good. Now, let's add the ability to open our dialog from our Header component.

To do this, we'll:

  • Add our Modal component into our Header component
  • Add some state to conditionally render Modal depending on if the user has clicked on the delete icon
const Header = () => {	const [showModal, setShowModal] = useState(false);	return (		<>			<div class="header-container">				{showModal && <Modal />}				<span class="icon-container">					<FolderIcon />				</span>				<span class="header-title">Main folder</span>				<span class="auto"></span>				<button class="icon-btn" onClick={() => setShowModal(true)}>					<DeleteIcon />				</button>			</div>		</>	);};

But wait... When we render the app and open our dialog, why does it look like it's under the Footer component?!

Note

If you're running the code embed above, you may have to open it in a new tab and try resizing your window to see the bug.

The dialog has some of its contents underneath the footer

Why is that? After all, Modal has a z-index of 99, while Footer only has a z-index of 2!

While the long answer of "Why is the modal rendering under the footer in this example" includes a mention of stacking contexts, the short answer is "A higher z-index number doesn't always guarantee that your element is always on the top."

While both of those links lead to the same place, I worry that this might still be too subtle of a hint to go read the article I wrote that explains exactly why this z-index behavior occurs.

To solve this, we'll reach for the JavaScript API built into React, Angular, and Vue that we mentioned at the start of this chapter: Portals.

What Is a JavaScript Portal?

The basic idea behind a JavaScript Portal builds on top of the concepts like components we introduced in our first chapter.

Imagine you have a set of components that represent the small app we just built:

A chart showcasing "App" at the root, footer, and header are next to one another as siblings, and "Modal" is a child of "Header."

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;

The header, body, and footer are siblings. The header has a group that's z-index 1, which contains the modal of z-index 99. The footer is a root z-index of 2

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:

We flattened our element structure so that our z-index of 99 is now a sibling of the z-index of 2 and 1

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:

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.

import { useMemo, useState } from "react";import { createPortal } from "react-dom";function App() {	const [portalRef, setPortalRef] = useState(null);	const portal = useMemo(() => {		if (!portalRef) return null;		return createPortal(<div>Hello world!</div>, portalRef);	}, [portalRef]);	return (		<>			<div				ref={(el) => setPortalRef(el)}				style={{ height: "100px", width: "100px", border: "2px solid black" }}			>				<div />			</div>			{portal}		</>	);}

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.

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

But wait, Corbin, there is a way we can provide all a variable to the rest of our app! We learned how to do that using dependency injection from the root of the app!

Good call, keen reader! Let's do that.

If we remember our dependency injection chapter, React uses a context to provide and consume data using dependency injection.

We can pair this with our createPortal API to keep track of where we want to provide a portal:

const PortalContext = createContext();function ChildComponent() {	const portalRef = useContext(PortalContext);	if (!portalRef) return null;	return createPortal(<div>Hello, world!</div>, portalRef);}function App() {	const [portalRef, setPortalRef] = useState(null);	return (		<PortalContext.Provider value={portalRef}>			<div				ref={(el) => setPortalRef(el)}				style={{ height: "100px", width: "100px", border: "2px solid black" }}			>				<div />			</div>			<ChildComponent />		</PortalContext.Provider>	);}

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:

Our files app has a chat dialog spawning from a corner button labeled "UnicornChat HQ"

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.

The UnicornChat is drawn above the dialog despite being less important for the user's dialog at the time

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.

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" }}			/>		</>	);}

Now, when you test the issue again, you find your modal is above the UnicornChat UI.

The modal is drawn above the UnicornChat elements, allowing users to press the dialogs

Challenge

If we look back to our Element Reference chapter's code challenge, you might remember that we were tasked with creating a tooltip component:

Hovering over a "send" button will show an alert above the button saying, "This will send an email to the recipients."

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.

The tooltip dialog is drawn underneath a header element due to z-index stacking contexts

To fix this, we'll need to wrap our tooltip in a portal and render it at the end of the body tag:

function App() {	const buttonRef = useRef();	const mouseOverTimeout = useRef();	const [tooltipMeta, setTooltipMeta] = useState({		x: 0,		y: 0,		height: 0,		width: 0,		show: false,	});	const onMouseOver = () => {		mouseOverTimeout.current = setTimeout(() => {			const bounding = buttonRef.current.getBoundingClientRect();			setTooltipMeta({				x: bounding.x,				y: bounding.y,				height: bounding.height,				width: bounding.width,				show: true,			});		}, 1000);	};	const onMouseLeave = () => {		setTooltipMeta({			x: 0,			y: 0,			height: 0,			width: 0,			show: false,		});		clearTimeout(mouseOverTimeout.current);	};	useEffect(() => {		return () => {			clearTimeout(mouseOverTimeout.current);		};	}, []);	return (		<div>			<div				style={{					height: 100,					width: "100%",					background: "lightgrey",					position: "relative",					zIndex: 2,				}}			/>			<div style={{ paddingLeft: "10rem", paddingTop: "2rem" }}>				{tooltipMeta.show &&					createPortal(						<div							style={{								zIndex: 9,								display: "flex",								overflow: "visible",								justifyContent: "center",								width: `${tooltipMeta.width}px`,								position: "fixed",								top: `${tooltipMeta.y - tooltipMeta.height - 16 - 6 - 8}px`,								left: `${tooltipMeta.x}px`,							}}						>							<div								style={{									whiteSpace: "nowrap",									padding: "8px",									background: "#40627b",									color: "white",									borderRadius: "16px",								}}							>								This will send an email to the recipients							</div>							<div								style={{									height: "12px",									width: "12px",									transform: "rotate(45deg) translateX(-50%)",									background: "#40627b",									bottom: "calc(-6px - 4px)",									position: "absolute",									left: "50%",									zIndex: -1,								}}							/>						</div>,						document.body,					)}				<button					onMouseOver={onMouseOver}					onMouseLeave={onMouseLeave}					ref={buttonRef}				>					Send				</button>			</div>		</div>	);}
Previous articleDependency Injection

Subscribe to our newsletter!

Subscribe to our newsletter to get updates on new content we create, events we have coming up, and more! We'll make sure not to spam you and provide good insights to the content we have.