AboutPostsTalksOpen sourceProjectsPodcastsVideosToolsResumeContact

Build an Easy Popup System With React

Demystifying the way to create a simple, customisable, and accessible popup system with React

Photo by Windows on Unsplash.
Photo by Windows on Unsplash.

Concerns About Existing Systems

There are plenty of popup systems out there, but they usually don’t meet the high-quality requirement I have on user interfaces and development simplicity.

When I’m adding a popup into a website, it is important to me that the system is:

  • Simple to use: as a developer, I don’t want to spend time creating tons of components and states just to activate a popup. A developer better spend their time on the domain specificities rather than brainless tasks
  • Customisable: this is usually my main point of complexity since popup systems are almost always shipped with styled components, making it - harder to make them look as close as your UI Designer has imagined them.
  • Accessible: Accessibility is usually created aside from the systems because it asks for more work, even if it doesn’t need that much work on it.

With these requirements, I always find it difficult to find a library with what I need and the blocking points are often too painful to be worked around.

Even if it might not be intuitive, the last standing option is to create our own system so that will ensure a perfect match with your needs

Enough speaking, let’s dive into a popup component system creation.

What Are We Building

There are a few things we want in this popup system:

  • A custom modal component that will be in charge of the popup style, including background, position, and a closing button
  • An easy-to-use modal component with a simple toggle system that will be in charge of the functional part of the popup.
  • A changeable state to make the CSS modal softly appear
  • Support for people who need a browser with reduced motion
  • Handling accessibility on the modal to tell people with disabilities the popup has appeared and where to click so the popup will be closed
  • A clickable background overlay to close the popup as we click out
  • Handle the escape key to close the popup

That’s a lot to do so we better get started.

Requirements

The first thing to have a modal system is to have a modal root, where the system will take place. To do so, we just need to have a new div#modal-root element in our root document.

This part is important so the modal can be easily styled. With a separate root element, we are sure that the parent elements of the modal does not have styles that will make it harder for us to reach the perfect style.

To be sure that the modal will always be on top of the document, we just need to add the right z-index on the application root and the modal-root.

Also, since the modal behavior is to be opened and directly occupy the whole browser’s page, we add an ARIA live region to the modal system so it can be announced to the user.

The aria live region is set to assertive because we want the readers to have the same behavior as the browser, which places the popup on top of everything else.

global.css
#root {
  position: relative;
  z-index: 1;
}
#modal-root {
  position: relative;
  z-index: 2;
}
index.html
#root {
  position: relative;
  z-index: 1;
}
#modal-root {
  position: relative;
  z-index: 2;
}

The modal components

The modal component is split into three different components:

  • A ModalPortal component that will link our modal to the div#modal-root element
  • A ModalView component that aims to handle the visible part of the component
  • A ModalAnimated component that will handle the popup domain and the CSS appearance effects of the popup system

The ModalPortal component

The ModalPortal component exists to link our popup to the div#modal-root element that we have created. Here’s the code:

ModalPortal.tsx
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";

interface IModalPortalProps {
  active: boolean;
  children: React.ReactNode;
}

export default function ModalPortal({
  active,
  children,
}: IModalPortalProps): React.ReactPortal | null {
  const elRef = useRef<HTMLDivElement>();

  useEffect(() => {
    if (window) {
      elRef.current = window.document.createElement("div");
    }
  }, []);

  useEffect(() => {
    const modalRoot = window.document.getElementById("modal-root");

    if (active && elRef.current && modalRoot !== null) {
      const { current } = elRef;
      modalRoot.appendChild(current);

      return () => {
        modalRoot.removeChild(current);
      };
    }

    return () => {};
  }, [active]);

  if (elRef.current && active) {
    return createPortal(children, elRef.current);
  }

  return null;
}

It is made of four sections:

  • A ref corresponding to a simple div element, with the goal of holding the popup content. We do not use directly the root element so we are able to create two or more different popups if we want to stack them.
  • A first useEffect hook to create the div element. This is a security to make the system work also on SSR systems such as NextJs or Gatsby.
  • Another useEffect hook, to add the previously created div in the portal when active, and remove it when inactive. It will prevent the div#modal-root element to contain plenty of empty divs.
  • The render part, which is null if neither the div element created does not exist or the popup is not currently active.

The ModalView component

This one is basically a layout component so we can style the popup the way we want.

Even if I’m presenting only one template, you are able to use it for as many needs you may have such as:

  • A popup system
  • A designed replacement of the native alert and confirm modal
  • A notification system
  • Whatever else you can imagine
ModalView.module.css
.Overlay {
  background-color: rgba(0, 0, 0, 0.3);
  border: 0;
  height: 100%;
  left: 0;
  padding: 0;
  position: fixed;
  top: 0;
  width: 100%;
  z-index: 1;
}
.Content {
  background: rgba(255, 255, 255, 1);
  border-radius: 16px;
  box-shadow: 0 10px 13px -6px rgba(0, 0, 0, 0.2), 0 20px 31px 3px rgba(0, 0, 0, 0.14),
    0 8px 38px 7px rgba(0, 0, 0, 0.12);
  color: rgba(0, 0, 0, 0.85);
  left: 50%;
  max-height: 80vh;
  max-width: 90vw;
  overflow: auto;
  padding: 32px;
  position: fixed;
  top: 50%;
  transform: translate(-50%);
  width: 600px;
  z-index: 2;
}
.Close {
  background-color: transparent;
  border-radius: 50%;
  border: 0;
  cursor: pointer;
  display: block;
  fill: rgba(0, 0, 0, 1);
  height: 40px;
  margin: -24px -24px 0 auto;
  padding: 8px;
  transition: all 0.15s cubic-bezier(0.4, 0, 0.6, 1);
  width: 40px;
}
.Close:hover {
  background-color: rgba(0, 0, 0, 0.15);
}
ModalView.tsx
.Overlay {
  background-color: rgba(0, 0, 0, 0.3);
  border: 0;
  height: 100%;
  left: 0;
  padding: 0;
  position: fixed;
  top: 0;
  width: 100%;
  z-index: 1;
}
.Content {
  background: rgba(255, 255, 255, 1);
  border-radius: 16px;
  box-shadow: 0 10px 13px -6px rgba(0, 0, 0, 0.2), 0 20px 31px 3px rgba(0, 0, 0, 0.14),
    0 8px 38px 7px rgba(0, 0, 0, 0.12);
  color: rgba(0, 0, 0, 0.85);
  left: 50%;
  max-height: 80vh;
  max-width: 90vw;
  overflow: auto;
  padding: 32px;
  position: fixed;
  top: 50%;
  transform: translate(-50%);
  width: 600px;
  z-index: 2;
}
.Close {
  background-color: transparent;
  border-radius: 50%;
  border: 0;
  cursor: pointer;
  display: block;
  fill: rgba(0, 0, 0, 1);
  height: 40px;
  margin: -24px -24px 0 auto;
  padding: 8px;
  transition: all 0.15s cubic-bezier(0.4, 0, 0.6, 1);
  width: 40px;
}
.Close:hover {
  background-color: rgba(0, 0, 0, 0.15);
}

The present component is just a bunch of native elements with some styles separated into two sections:

  • An overlay button, so the popup can be closed when clicking out
  • The popup content itself, including a close button

The two blocks are siblings because we don’t want the click event to propagate from one to the other.

For accessibility reasons, both the overlay and the close buttons are native button elements with an aria-label attribute.

In the CSS part, I use various positioning techniques that you are free to adapt depending on your needs.

The ModalAnimated component

For the last part of the system, we need a component that will control the modal. Here’s the code:

ModalAnimated.module.css
.ModalAnimated {
  bottom: 0;
  left: 0;
  pointer-events: initial;
  position: absolute;
  right: 0;
  top: 0;
}
.ModalAnimated:global(.modal-enter) {
  opacity: 0;
  transform: translateY(100px) scale(0.9);
}
.ModalAnimated:global(.modal-enter-active) {
  opacity: 1;
  transform: translateY(0) scale(1);
  transition: all cubic-bezier(0, 0, 0.2, 1) 300ms;
}
.ModalAnimated:global(.modal-exit) {
  opacity: 1;
  transform: translateY(0) scale(1);
}
.ModalAnimated:global(.modal-exit-active) {
  opacity: 0;
  transform: translateY(-100px) scale(0.9);
  transition: all cubic-bezier(0, 0, 0.2, 1) 300ms;
}
@media screen and (prefers-reduced-motion: reduce) {
  .ModalAnimated:global(.modal-enter-active) {
    transition: none;
  }
  .ModalAnimated:global(.modal-exit-active) {
    transition: none;
  }
}
ModalAnimated.tsx
.ModalAnimated {
  bottom: 0;
  left: 0;
  pointer-events: initial;
  position: absolute;
  right: 0;
  top: 0;
}
.ModalAnimated:global(.modal-enter) {
  opacity: 0;
  transform: translateY(100px) scale(0.9);
}
.ModalAnimated:global(.modal-enter-active) {
  opacity: 1;
  transform: translateY(0) scale(1);
  transition: all cubic-bezier(0, 0, 0.2, 1) 300ms;
}
.ModalAnimated:global(.modal-exit) {
  opacity: 1;
  transform: translateY(0) scale(1);
}
.ModalAnimated:global(.modal-exit-active) {
  opacity: 0;
  transform: translateY(-100px) scale(0.9);
  transition: all cubic-bezier(0, 0, 0.2, 1) 300ms;
}
@media screen and (prefers-reduced-motion: reduce) {
  .ModalAnimated:global(.modal-enter-active) {
    transition: none;
  }
  .ModalAnimated:global(.modal-exit-active) {
    transition: none;
  }
}

This component has several tasks to handle:

  • It has to load the ModalView component. By default, I chose to use the ModalView component, but I also give the component a prop to be able to change it
  • It also has to manage the Modal portal component to include our content in the div#modal-root DOM element
  • It gives us access to an escape key support to close the modal.
  • Finally, it handles a nice but optional transition effect.

The CSS has a weird CSS Modules syntax to handle global classes, but it also uses the prefers-reduced-motion media query to shutdown the animation for people asking for it.

If the last part could be set globally for all elements, it is better illustrated in the component.

The useEscape hook

To improve usability, we can add another great feature to our popup system by adding an escape listener that can close the popup.

To do so, there is a useEscape(active, onClose); code in the ModalAnimated component, but this is yet to be implemented. Here’s the code:

useEscape.ts
import { useCallback, useEffect } from "react";

export default function useEscape(active: boolean, onClose: () => void) {
  const onEchap = useCallback(
    (event) => {
      if (event.keyCode === 27) {
        onClose();
      }
    },
    [onClose]
  );

  useEffect(() => {
    if (active) {
      window.addEventListener("keydown", onEchap);

      return () => {
        window.removeEventListener("keydown", onEchap);
      };
    }
  }, [onEchap, active]);
}

The hook is quite simple, and it is made of two blocks:

  • an onEscape callback that memoize the keyboard event by listening to the keyCode for the escape key — 27
  • an useEffect method to bind it to the window document and unbind it as soon as the modal is unmounted

The usage

The usage is pretty straightforward: we need the ModalAnimated component with two props if we want a custom ModalView component.

The content of the popup itself is just the children elements passed to ModalAnimated. I usually put the content inside another component to keep the page as light as possible. Here’s the code:

PopinUsageSample.tsx
import React, { useState } from "react";
import ModalAnimated from "../components/modal/ModalAnimated";

interface Props {
  [key: string]: never;
}

const PopinUsageSample: React.FC<Props> = () => {
  const [active, setActive] = useState(false);

  return (
    <>
      <ModalAnimated active={active} onClose={() => setActive(false)}>
        Hello, world!
      </ModalAnimated>
      <button onClick={() => setActive(true)}>Open a popin</button>
    </>
  );
};

export default PopinUsageSample;

Conclusion

By creating three light components and a simple custom hook, we are able to get a very modulable and customizable popup system.

While it can still be improved, we have implemented a system that will make your UI designer happy, and it implements the accessibility basics.

Did we check all the initial requirements?

  • Simple to use: yes
  • Customisable: we can customise the view very easily
  • Accessible: We do have a11y included in the code

Mission accomplished! Now it is your turn to use it and improve it in your projects.

Happy coding!

You liked the post? Consider donating!
Become a patron
Buy me a coffee