Modals are very useful for quickly getting a user’s attention. They can be used to collect user information, provide an update, or encourage a user to take action. A study of 2 billion pop-ups revealed that the top 10 percent of performers had a conversion rate of over 9 percent.
However, I think it’s fair to say that modals can take some patience to build. It’s not easy to keep track of all the z-index values, the layers, and the DOM hierarchy. This difficulty also extends to other elements that need to be rendered at the top level, such as overlays or tooltips.
In React apps, a component or element is mounted into the DOM as a child of the nearest parent node. From top to bottom, the standard layer hierarchy is as follows:
root node => parent nodes => child nodes => leaf nodes
If the parent node has an overflow property set to hidden, or has elements at higher layers, then the child cannot appear on the top layer and is limited to the parent node’s visible area. We can try setting a very high z-index value to bring the child to the top layer, but this strategy can be tedious and is not always successful.
This is where React Portals are advantageous. React Portals provides the ability for an element to render outside the default hierarchy without compromising the parent-child relationship between components.
In this article, we’ll cover:
wrapperId
The methods used in this article can also be applied to building:
So, without further ado, let’s get this magic started.
Let’s start by creating a new React app with the Create React App boilerplate or your own React app setup.
# using yarn yarn create react-app react-portal-overlay # using npx npx create-react-app react-portal-overlay
Next, change to the app directory and start the React app:
# cd into app directory cd react-portal-overlay # start using yarn yarn start # start using npm npm run start
We’ll create two components and render them within the already available App
component from the Create React App boilerplate.
But first, here are some important component definitions:
ReactPortal
: a wrapper component that creates a Portal and renders content in the provided container outside the default hierarchyModal
: a basic modal component with JSX content to be rendered using the ReactPortal
App
(any component): the location where we will use the Modal
component and maintain its active state (open or closed)A React Portal can be created using the createPortal
function imported from react-dom
. It takes two arguments:
content
: any valid renderable React elementcontainerElement
: a valid DOM element to which we can append the content
ReactDOM.createPortal(content, containerElement);
We’ll create a new component, ReactPortal.js
, under the src/components
directory and add this snippet:
// src/components/ReactPortal.js import { createPortal } from 'react-dom'; function ReactPortal({ children, wrapperId }) { return createPortal(children, document.getElementById(wrapperId)); } export default ReactPortal;
The ReactPortal
component accepts the wrapperId
property, which is the ID attribute of a DOM element and acts as the container
for the portal.
It’s important to note that the createPortal()
function will not create the containerElement
for us. The function expects the container
to be available in the DOM already; we must add it ourselves in order for the portal to render content within the element.
We can customize the ReactPortal
component to create an element with the provided ID, if such an element is not found in the DOM.
First, we add a helper function to create an empty div
with a given ID, append it to the body, and return the element.
function createWrapperAndAppendToBody(wrapperId) { const wrapperElement = document.createElement('div'); wrapperElement.setAttribute("id", wrapperId); document.body.appendChild(wrapperElement); return wrapperElement; }
Next, let’s update the ReactPortal
component to use the createWrapperAndAppendToBody
helper method:
// Also, set a default value for wrapperId prop if none provided function ReactPortal({ children, wrapperId = "react-portal-wrapper" }) { let element = document.getElementById(wrapperId); // if element is not found with wrapperId, // create and append to body if (!element) { element = createWrapperAndAppendToBody(wrapperId); } return createPortal(children, element); }
This method has a limitation. If the wrapperId
property changes, the ReactPortal
component will fail to handle the latest property value. To fix this, we need to move any logic that is dependent on the wrapperId
to another operation or side effect.
The default behavior of the DOM hierarchy when no z-index is set to elements is that the elements that appear lower in the hierarchy will take higher precedence. In simpler terms, the order matters. So, appending to the body (after all the elements) in the DOM will ensure the portal container element will have higher precedence in hierarchy.
<body> <div id="root" /> <div id="portal-root" /> </body>
In the above snippet, the div with ID portal-root
will have higher precedence because it appears later. It’s totally up to you to directly modify the HTML or programmatically add the portal root; for the sake of this post, we will programmatically add and remove the portal container element.
wrapperId
The React Hooks useLayoutEffect
and useEffect
achieve similar results but have slightly different usages. A quick rule of thumb is to use useLayoutEffect
if the effect needs to be synchronous and also if there are any direct mutations on the DOM. Since this is pretty rare, useEffect
is usually the best option. useEffect
runs asynchronously.
In this case, we’re directly mutating the DOM and want the effect to run synchronously before the DOM is repainted, so it makes more sense to use the useLayoutEffect
Hook.
First, let’s move the find
element and creation logic into the useLayoutEffect
Hook, with wrapperId
as the dependency. Next, we’ll set the element
to state. When the wrapperId
changes, the component will update accordingly.
import { useState, useLayoutEffect } from 'react'; // ... function ReactPortal({ children, wrapperId = "react-portal-wrapper" }) { const [wrapperElement, setWrapperElement] = useState(null); useLayoutEffect(() => { let element = document.getElementById(wrapperId); // if element is not found with wrapperId or wrapperId is not provided, // create and append to body if (!element) { element = createWrapperAndAppendToBody(wrapperId); } setWrapperElement(element); }, [wrapperId]); // wrapperElement state will be null on the very first render. if (wrapperElement === null) return null; return createPortal(children, wrapperElement); }
Now, we need to address cleanup.
We are directly mutating the DOM and appending an empty div
to the body in instances where no element is found. Therefore, we need to ensure that the dynamically added empty div
is removed from the DOM when the ReactPortal
component is unmounted. Also, we must avoid removing any existing elements during the cleanup process.
Let’s add a systemCreated
flag and set it to true
when createWrapperAndAppendToBody
is invoked. If the systemCreated
is true
, we’ll delete the element from the DOM. The updated useLayoutEffect
will look something like this:
// ... useLayoutEffect(() => { let element = document.getElementById(wrapperId); let systemCreated = false; // if element is not found with wrapperId or wrapperId is not provided, // create and append to body if (!element) { systemCreated = true; element = createWrapperAndAppendToBody(wrapperId); } setWrapperElement(element); return () => { // delete the programatically created element if (systemCreated && element.parentNode) { element.parentNode.removeChild(element); } } }, [wrapperId]); // ...
We’ve created the portal and have customized it to be fail-safe. Next, let’s create a simple modal component and render it using React Portal.
To build the modal component, we’ll first create a new directory, Modal
, under src/components
and add two new files, Modal.js
and modalStyles.css
.
The modal component accepts a couple of properties:
isOpen
: a boolean flag that represents the modal’s state (open or closed) and is controlled in the parent component that renders the modalhandleClose
: a method that is called by clicking the close button or by any action that triggers a closeThe modal component will render content only when isOpen
is true
. The modal component will return null
on false
, as we do not want to keep the modal in the DOM when it is closed.
// src/components/Modal/Modal.js import "./modalStyles.css"; function Modal({ children, isOpen, handleClose }) { if (!isOpen) return null; return ( <div className="modal"> <button onClick={handleClose} className="close-btn"> Close </button> <div className="modal-content">{children}</div> </div> ); } export default Modal;
Now, let’s add some styling to the modal:
/* src/components/Modal/modalStyles.css */ .modal { position: fixed; inset: 0; /* inset sets all 4 values (top right bottom left) much like how we set padding, margin etc., */ background-color: rgba(0, 0, 0, 0.6); display: flex; flex-direction: column; align-items: center; justify-content: center; transition: all 0.3s ease-in-out; overflow: hidden; z-index: 999; padding: 40px 20px 20px; } .modal-content { width: 70%; height: 70%; background-color: #282c34; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 2rem; }
This code will make the modal occupy the full viewport and will center-align the .modal-content
both vertically and horizontally.
The modal may be closed by clicking the Close
button, triggering handleClose
. Let’s also add the ability to close the modal by pressing the escape key. To accomplish this, we’ll attach the useEffect
keydown event listener. We’ll remove the event listener on the effect cleanup.
On a keydown event, we’ll invoke handleClose
if the Escape
key was pressed:
// src/components/Modal/Modal.js import { useEffect } from "react"; import "./modalStyles.css"; function Modal({ children, isOpen, handleClose }) { useEffect(() => { const closeOnEscapeKey = e => e.key === "Escape" ? handleClose() : null; document.body.addEventListener("keydown", closeOnEscapeKey); return () => { document.body.removeEventListener("keydown", closeOnEscapeKey); }; }, [handleClose]); if (!isOpen) return null; return ( <div className="modal"> <button onClick={handleClose} className="close-btn"> Close </button> <div className="modal-content">{children}</div> </div> ); }; export default Modal;
Our modal component is now ready for action!
Let’s render the demo Modal
component in an app.
To control the modal’s open and close behavior, we’ll initialize the state isOpen
with the useState
Hook and set it to default to false
. Next, we’ll add a button click, button onClick
, that sets the isOpen
state to true
and opens the modal.
Now, we’ll send isOpen
and handleClose
as properties to the Modal
component. The handleClose
property is simply a callback method that sets the isOpen
state to false
in order to close the modal.
// src/App.js import { useState } from "react"; import logo from "./logo.svg"; import Modal from "./components/Modal/Modal"; import "./App.css"; function App() { const [isOpen, setIsOpen] = useState(false); return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <button onClick={() => setIsOpen(true)}> Click to Open Modal </button> <Modal handleClose={() => setIsOpen(false)} isOpen={isOpen}> This is Modal Content! </Modal> </header> </div> ); } export default App;
The modal may be opened by clicking the Click to Open Modal button. The modal may be closed by pressing the escape key or by clicking the Close button. Either action will trigger the handleClose
method and will close the modal.
If we take a look at the DOM tree, we see that the modal
is rendered as a child to the header
according to the default DOM hierarchy.
Let’s wrap the modal’s return JSX with ReactPortal
so that the modal is rendered outside of the DOM hierarchy and within the provided container element. A dynamic container is appended as the last child of the body within the DOM.
The updated return method for the Modal
component should look something like this:
// src/components/Modal/Modal.js import ReactPortal from "../ReactPortal"; // ... function Modal({ children, isOpen, handleClose }) { // ... return ( <ReactPortal wrapperId="react-portal-modal-container"> <div className="modal"> // ... </div> </ReactPortal> ); } // ...
Since we haven’t added a container with a react-portal-modal-container
ID, an empty div
will be created with this ID, and then it will be appended to the body. The Modal
component will be rendered inside this newly created container, outside of the default DOM hierarchy. Only the resulting HTML and the DOM tree are changed.
The React component’s parent-child relationship between the header and Modal
component remains unchanged.
As shown below, our demo modal renders correctly, but the opening and closing of its UI feels too instantaneous:
To adjust the transition of the modal’s opening and closing, we can remove the return null
when the Modal
component is closed. We can control the modal’s visibility through CSS, using the opacity
and transform
properties and a conditionally added class, show/hide
.
This show/hide
class can be used to set or reset the visibility and use transition property to animate the opening and closing. This works well, except that the modal remains in the DOM even after closing.
We can also set the display
property to none
, but this has the same result as the return null
. Both properties instantly remove the element from the DOM without waiting for the transitions or animations to complete. This is where the CSSTransition
component comes to the rescue.
By wrapping the element to be transitioned in the CSSTransition
component and setting the unmountOnExit
property to true
, the transition will run and then the element will be removed from the DOM once the transition is complete.
First, we install the react-transition-group
dependency:
# using yarn yarn add react-transition-group # using npm npm install react-transition-group
Next, we import the CSSTransition
component and use it to wrap everything under ReactPortal
in the modal’s return JSX.
The trigger, duration, and styles of the component can all be controlled by setting the CSSTransition
properties:
in
: Boolean flag that triggers the entry or exit statestimeout
: duration of the transition at each state (entry, exit, etc.)unmountOnExit
: unmounts the component after exitingclassNames
: class name will be suffixed for each state (entry, exit, etc.) to give control over CSS customizationnodeRef
: a React reference to the DOM element that needs to transition (in this case, the root div
element of the Modal
component)A ref
can be created using the useRef
Hook. This value is passed to CSSTransition
‘s nodeRef
property. It is attached as a ref
attribute to the Modal
’s root div
to connect the CSSTransition
component with the element that needs to be transitioned.
// src/components/Modal/Modal.js import { useEffect, useRef } from "react"; import { CSSTransition } from "react-transition-group"; // ... function Modal({ children, isOpen, handleClose }) { const nodeRef = useRef(null); // ... // if (!isOpen) return null; <-- Make sure to remove this line. return ( <ReactPortal wrapperId="react-portal-modal-container"> <CSSTransition in={isOpen} timeout={{ entry: 0, exit: 300 }} unmountOnExit classNames="modal" nodeRef={nodeRef} > <div className="modal" ref={nodeRef}> // ... </div> </CSSTransition> <ReactPortal wrapperId="react-portal-modal-container"> ); } // ....
Next, let’s add some transition styling for the state prefixed classes, modal-enter-done
and modal-exit
, added by the CSSTransition
component:
.modal { ... opacity: 0; pointer-events: none; transform: scale(0.4); } .modal-enter-done { opacity: 1; pointer-events: auto; transform: scale(1); } .modal-exit { opacity: 0; transform: scale(0.4); } ...
The opening and closing of the demo modal’s UI now appears smoother, and this was achieved without compromising the load on the DOM:
Portals are the best fits for elements that you require to appear on top of all other elements. Profile hovercards, for example, are a popular way to provide quick information about the user’s profile without having to click and visit the profile itself — i.e., to deliver information more quickly.
Similarly, full-page loading screens are a good way to use Portals. When a longer task is in process, it’s ideal to block user action on the entire screen to avoid accidental cancellations. (Fun fact: you can also list tips and tricks or promote lesser-known features about the app itself to keep the user engaged during this loading time!)
You may also want to use a Portal for things like dropdowns and tooltips, which often are cut away due to limited viewport of the parent container.
There are three important things you should remember about working with React Portals:
In this article, we demonstrated the functionality of React Portals with a React Portal modal example. However, the application of React Portals is not limited to only modals or overlays. We can also leverage React Portals to render a component on top of everything at the wrapper level.
By wrapping the component’s JSX or the component itself with ReactPortal
, we can alter the default DOM hierarchy behavior and get the benefits of React Portals on any component:
import ReactPortal from "./path/to/ReactPortal"; function AnyComponent() { return ( <ReactPortal wrapperId="dedicated-container-id-if-any"> {/* components JSX to render */} </ReactPortal> ); }
That’s all for now! You can find this article’s final components and styles in this GitHub repo, and access the final ReactPortal
and modal components in action.
Thank you for reading. I hope you found this article helpful. Please share it with others who may find it beneficial. Ciao!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowExplore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.
Build a real-time image background remover in Vue using Transformers.js and WebGPU for client-side processing with privacy and efficiency.
Optimize search parameter handling in React and Next.js with nuqs for SEO-friendly, shareable URLs and a better user experience.
One Reply to "Building a modal in React with React Portals"
Thank you so much for providing a so valuable content.