DEV Community

Cover image for React: Using portals to make a modal popup
Andrew Bone
Andrew Bone

Posted on • Updated on

React: Using portals to make a modal popup

This week we'll be making a modal popup, we'll be making it using portals and inert. Both of which are very cool in their own right. I'll be making a portal component we can use to help with the modal, but I'll try and make it in such a way it's helpful for future projects too.

Here's what we're going to make.

Portals

What are portals? Portals are a way to render children into a DOM node anywhere within your app, be it straight into the body or into a specific container.

How is that useful? Specifically in our component it means we can have our <Modal> component anywhere and append the content to the end of the body so it's always over the top of everything. It will also be helpful with setting inert on everything except our <Modal>.

How do I use it? Portals are on ReactDOM you call the function createPortal. This function takes 2 parameters the child, element(s) to spawn, and the container, where to spawn them. Generally you'd expect it to look a little something like this.

return ReactDOM.createPortal(
  this.props.children,
  document.body
);
Enter fullscreen mode Exit fullscreen mode

Portal Component

I'm going to take the relatively simple createPortal and add a layer of complexity and contain it within a component. Hopefully this will make using the <Portal> easier down the line.

Let's dive into the code.

// imports
import React from "react";
import ReactDOM from "react-dom";

// export function
// get parent and className props as well as the children
export default function Portal({ children, parent, className }) {
  // Create div to contain everything
  const el = React.useMemo(() => document.createElement("div"), []);
  // On mount function
  React.useEffect(() => {
    // work out target in the DOM based on parent prop
    const target = parent && parent.appendChild ? parent : document.body;
    // Default classes
    const classList = ["portal-container"];
    // If className prop is present add each class the classList
    if (className) className.split(" ").forEach((item) => classList.push(item));
    classList.forEach((item) => el.classList.add(item));
    // Append element to dom
    target.appendChild(el);
    // On unmount function
    return () => {
      // Remove element from dom
      target.removeChild(el);
    };
  }, [el, parent, className]);
  // return the createPortal function
  return ReactDOM.createPortal(children, el);
}
Enter fullscreen mode Exit fullscreen mode

Inert

What is inert? Inert is a way to let the browser know an element, and it's children, should not be in the tab index nor should it appear in a page search.

How is that useful? Again looking at our specific needs it means the users interactions are locked within the <Modal> so they can't tab around the page in the background.

How do I use it? Inert only works in Blink browsers, Chrome, Opera and Edge, at the moment but it does have a very good polyfill. Once the polyfill is applied you simply add the inert keyword to the dom element.

<aside inert class="side-panel" role="menu"></aside>
Enter fullscreen mode Exit fullscreen mode
const sidePanel = document.querySelector('aside.side-panel');
sidePanel.setAttribute('inert', '');
sidePanel.removeAttribute('inert');
Enter fullscreen mode Exit fullscreen mode

Modal

Now let's put it all together, I'll break the code down into 3 sections styles, events + animations and JSX.

Styles

I'm using styled-components, I'm not really going to comment this code just let you read through it. It's really just CSS.

const Backdrop = styled.div`
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background-color: rgba(51, 51, 51, 0.3);
  backdrop-filter: blur(1px);
  opacity: 0;
  transition: all 100ms cubic-bezier(0.4, 0, 0.2, 1);
  transition-delay: 200ms;
  display: flex;
  align-items: center;
  justify-content: center;

  & .modal-content {
    transform: translateY(100px);
    transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
    opacity: 0;
  }

  &.active {
    transition-duration: 250ms;
    transition-delay: 0ms;
    opacity: 1;

    & .modal-content {
      transform: translateY(0);
      opacity: 1;
      transition-delay: 150ms;
      transition-duration: 350ms;
    }
  }
`;

const Content = styled.div`
  position: relative;
  padding: 20px;
  box-sizing: border-box;
  min-height: 50px;
  min-width: 50px;
  max-height: 80%;
  max-width: 80%;
  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
  background-color: white;
  border-radius: 2px;
`;
Enter fullscreen mode Exit fullscreen mode

Events + Animations

// set up active state
const [active, setActive] = React.useState(false);
// get spread props out variables
const { open, onClose, locked } = props;
// Make a reference to the backdrop
const backdrop = React.useRef(null);

// on mount
React.useEffect(() => {
  // get dom element from backdrop
  const { current } = backdrop;
  // when transition ends set active state to match open prop
  const transitionEnd = () => setActive(open);
  // when esc key press close modal unless locked
  const keyHandler = e => !locked && [27].indexOf(e.which) >= 0 && onClose();
  // when clicking the backdrop close modal unless locked
  const clickHandler = e => !locked && e.target === current && onClose();

  // if the backdrop exists set up listeners
  if (current) {
    current.addEventListener("transitionend", transitionEnd);
    current.addEventListener("click", clickHandler);
    window.addEventListener("keyup", keyHandler);
  }

  // if open props is true add inert to #root
  // and set active state to true
  if (open) {
    window.setTimeout(() => {
      document.activeElement.blur();
      setActive(open);
      document.querySelector("#root").setAttribute("inert", "true");
    }, 10);
  }

  // on unmount remove listeners
  return () => {
    if (current) {
      current.removeEventListener("transitionend", transitionEnd);
      current.removeEventListener("click", clickHandler);
    }

    document.querySelector("#root").removeAttribute("inert");
    window.removeEventListener("keyup", keyHandler);
  };
}, [open, locked, onClose]);
Enter fullscreen mode Exit fullscreen mode

JSX

The main thing to see here is (open || active) this means if the open prop or the active state are true then the portal should create the modal. This is vital in allowing the animations to play on close.

Backdrop has className={active && open && "active"} which means only while the open prop and active state are true the modal will be active and animate into view. Once either of these become false the modal will animate away for our transition end to pick up.

return (
  <React.Fragment>
    {(open || active) && (
      <Portal className="modal-portal">
        <Backdrop ref={backdrop} className={active && open && "active"}>
          <Content className="modal-content">{props.children}</Content>
        </Backdrop>
      </Portal>
    )}
  </React.Fragment>
);
Enter fullscreen mode Exit fullscreen mode

Fin

And that's a modal popup in ReactJS, I hope you found this helpful and maybe have something to take away. As always I'd love to see anything you've made and would love to chat down in the comments. If I did anything you don't understand feel free to ask about it also if I did anything you think I could have done better please tell me.

Thank you so much for reading!
🦄❤️🤓🧠❤️💕🦄🦄🤓🧠🥕

Top comments (14)

Collapse
 
nickytonline profile image
Nick Taylor

Nice! We use portals on DEV as well. For the moment, just in the moderation center though.

github.com/forem/forem/blob/master...

Looking forward to your next post Andrew!

Collapse
 
link2twenty profile image
Andrew Bone • Edited

The DEV codebase looks so different to when I used to know it 😅

I've been looking at crayons a little today, some really nice stuff in there 😁

Collapse
 
nickytonline profile image
Nick Taylor • Edited

Crayons is the code name for our design system. You can see our work in progress Storybook at storybook.dev.to. I thought storybook.forem.com was working, but looks like some DNS stuff needs to be sorted in Netlify.

We also have a bunch of Tailwind inspired utility classes. However, they are not surfaced in documentation at the moment. This PR fixes that.

You can see the utility class docs in the Storybook for the PR.

Collapse
 
xodeeq profile image
Babatunde Adenowo

I'm implementing this using typescript and the latest nextjs 13. Two things:

  1. I think this is a perfect idea (using react portals), as my main layout.tsx is a server component and putting a dynamic client-rendered portal directly in it may result in component poisoning somehow (maybe not, but I'm being cautious).
  2. I'm struggling with finding the right typescript types for a lot of the properties, primarily, the props to the Portal wrapper component. I'll be glad if anyone could help me out with the right types. Currently, I have { children: React.ReactNode, parent: React.ReactNode, className: string} but I see that the parent type is still wrong as React.ReactNode doesn't have the appendChild property or method.

Great piece @link2twenty, Thank you.

Collapse
 
rain84 profile image
rain84

@link2twenty , thank you for interesting article

I have a question.
Why you are using

[27].indexOf(e.which)
Enter fullscreen mode Exit fullscreen mode

instead of

e.which === 27
Enter fullscreen mode Exit fullscreen mode

?

Collapse
 
link2twenty profile image
Andrew Bone

It's for future proofing, whenever I want a specific key I do it that way so I can add extra keys to the array down the line.

For instance if I wanted to add the enter key I would do this.

[27, 13].indexOf(e.which)

Collapse
 
n1rjal profile image
Nirjal Paudel

That is a really nice blog. I actually wanted to learn on making a popup form on react portal popup. How can we do that ? Do you hav any idea

Collapse
 
link2twenty profile image
Andrew Bone

You can use this code and pass in a form as the child.

Collapse
 
n1rjal profile image
Nirjal Paudel

The method is techically correct but not "REACT esk". If I would use, say email and password field, in the form and make it a controlled component by putting value and setValue. Each time the state changes due to onChange, the code freezes for split second and UX will be poor

Thread Thread
 
link2twenty profile image
Andrew Bone

That's React's official documentation. A redraw, due to state change, should not cause anything to freeze.

Thread Thread
 
link2twenty profile image
Andrew Bone

Here's an example

Collapse
 
arqex profile image
Javier Marquez

Very nice the idea of wrapping the createPortal call in a component to allow the animation! I'll definitely use it.

Collapse
 
link2twenty profile image
Andrew Bone

In this code it needs to be an HTMLElement, I would generally use ref.