DEV Community

Cover image for Create Modal Dialogs in React 🍭
Andreas RiedmĂĽller
Andreas RiedmĂĽller

Posted on

Create Modal Dialogs in React 🍭

Most apps use modal dialogs (aka modals), and implementing a simple one is not that hard: a div with some content, a bit of CSS for the backdrop and positioning, and voilà, you have a modal. But as your app grows, so do the requirements for your modals. Sooner or later, you may find yourself refactoring and debugging a surprising amount of code that is related to modals. At least that’s what happened to me.

Accessibility, keyboard navigation, focus trapping, modal stacking, you name it.

In this guide, I’ll walk you through a concise yet comprehensive modal dialog implementation in React. It’s the result of what I’ve learned about modals in React over the past few years. There are libraries for modals out there, but if you want an implementation you fully own and deeply understand, this guide is for you. We use this architecture in production for the customer dashboard at Userbrain.

I’ve split this article into three parts. First, we’ll build the foundation of the modal framework. Next, we’ll add essential features for usability and accessibility. Finally, we’ll explore more advanced use cases and how to make the most of the system.

What you will learn:

Part I

  • How to use React Portals to render modals and why.
  • How to manage multiple modal dialogs that can be open simultaneously.
  • How to style and control modal stacking visually

Part II

  • How to trap focus inside a modal.
  • How to close modals via the Escape key and by clicking outside.
  • What you need to consider to meet modern accessibility guidelines.
  • Learn more about the HTML <dialog> element

Part III

  • Create different kinds of modals
  • Advanced styling and transitions
  • How to manage global modals
  • How to control modals with routes

Let’s start with the end in mind

We’re going to build a React component that can render a modal anywhere in your app. Its open state will be controlled by the parent component.

<Modal isOpen={isModalOpen}>
  Hello, Modal!
</Modal>
Enter fullscreen mode Exit fullscreen mode

A Modal component can also be rendered inside another Modal, which leads to a hierarchical structure of modals.

<Modal isOpen={isModalOpen}>
  Hello, Modal!
  <Modal isOpen={isNestedModalOpen}>
    This is a nested modal.
  </Modal>
</Modal>
Enter fullscreen mode Exit fullscreen mode

A modal dialog, by definition, blocks interaction with the rest of the application.

Because multiple modals can be open at the same time, we also need to determine which one is currently active.

In the following example, two sibling modals and one nested modal are rendered:

<Modal isOpen={isModalOneOpen}>
  This is Modal One.
</Modal>
<Modal isOpen={isModalTwoOpen}>
  This is Modal Two.
  <Modal isOpen={isConfirmModalOpen}>
    Are your sure you want to close Modal Two?
    <button onClick={handleClickYes}>Yes, close!</button>
    <button onClick={handleClickNevermind}>Nevermind</button>
  </Modal>
</Modal>
Enter fullscreen mode Exit fullscreen mode

Let’s think about this example for a second. If all isOpen props are true, which modal should be active?

For Modal Two and its nested modal, the answer is obvious: the Confirm Modal must be active, not its parent, Modal Two.

But Modal One could also be the active modal in this example.

We could use something like the order of appearance in JSX to determine the active modal, but that’s neither intuitive nor easy to implement in React.

A more intuitive approach, in my opinion, is to treat the most recently opened modal as the active one. This also aligns with how the HTML <dialog> element behaves and is the strategy we use.

Now that you have a general idea of what we are going to implement, let’s start writing some code.

1. The portal

Portals are a feature of React that allows you to render JSX into a specific DOM node that can be located anywhere in your document.

We will use Portals to render all modals into a "modal container" node outside the main app tree. By doing so, we make sure our modals aren’t affected by parent styles and we can more easily use features like inert to disable background interaction for better accessibility.

If you want to follow along, start with a fresh TypeScript React project using npm create vite@latest.

First, create a ref to a DOM node where all modals will be rendered to:

// App.tsx
function App() {
  const refModalContainer = useRef<HTMLDivElement>(null);

  return (
    <>
      {/* … */}
      <div ref={refModalContainer} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Keep in mind that the location of this node in the DOM influences which CSS rules, variable values, and other DOM-dependent behaviors apply to your modals.

You can also use a ref to any other DOM node, such as document.body. I intentionally use a DOM node inside the JSX tree because it requires a bit of extra work, and I want to demonstrate how to do that.

Every modal needs to know about this DOM node, so let’s create a context that provides this information:

// context.ts
type ModalContextType = {
  refModalContainer: RefObject<HTMLElement | null>
} | null;

export const ModalContext = createContext<ModalContextType>(null);
Enter fullscreen mode Exit fullscreen mode

And a ModalProvider component that is responsible for providing this context:

// ModalProvider.tsx
type ModalProviderProps = {
  children: React.ReactNode;
  refModalContainer: React.RefObject<HTMLDivElement | null>;
};

export function ModalProvider({ children, refModalContainer }: ModalProviderProps) {
  const modalContextValue = {
    refModalContainer,
  };

  return (
    <ModalContext.Provider value={modalContextValue}>
      {children}
    </ModalContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

To make this context easy to consume, we create a useModalContext hook. It throws an error if used outside of a ModalProvider. This is especially helpful when working with TypeScript, as it guarantees that the context exists when the hook is used.

// useModalContext.ts
export function useModalContext() {
  const modalContext = useContext(ModalContext);

  if (modalContext === null) {
    throw new Error("useModalContext must be used within a ModalProvider.");
  }

  return modalContext;
}
Enter fullscreen mode Exit fullscreen mode

Then wrap the entire app in a ModalProvider:

// App.tsx
  const refModalContainer = useRef<HTMLDivElement>(null);

  return (
    <>
      <ModalProvider refModalContainer={refModalContainer}>
        {/* … */}
      </ModalProvider>
      <div ref={refModalContainer} />
    </>
  );
Enter fullscreen mode Exit fullscreen mode

Next, we create a component named ModalPortal that simply renders its children into the modal container:

// ModalPortal.tsx
export function ModalPortal({
  children,
}: {
  children: React.ReactNode;
}) {
  const { refModalContainer } = useContext(ModalContext);

  if (refModalContainer.current !== null) {
    return createPortal(
      children,
      refModalContainer.current,
    );
  } else {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

You can now try using <ModalPortal>, and its children should be rendered into the modal container.

// App.tsx
<ModalProvider refModalContainer={refModalContainer}>
  <ModalPortal>
    Hello, Modal Portal!
  </ModalPortal>
  /* … */
<ModalProvider />
Enter fullscreen mode Exit fullscreen mode

However, there is a catch: The modal container div is only added to the DOM after React has completed rendering, so refModalContainer.current is null during the first render.

"Hello, Modal Portal!" will be rendered into the modal container as soon as you trigger a second render. If you still have the default Vite code in App(), you can click the "count is …" button to trigger another render.

You would not run into this problem if you used an already existing element, such as document.body.

We will fix this issue by adding a mechanism that automatically re-renders the portal if it was not created:

// ModalPortal.tsx
+  const [, forceUpdate] = useReducer((x) => x + 1, 0);
   /* … */
+  const refIsPortalCreated = useRef(false);
+  useEffect(() => {
+    // This effect will run once for any given ref
+    if (refIsPortalCreated.current === false) {
+      forceUpdate();
+    }
+  }, [refModalContainer]);

  if (refModalContainer.current !== null) {
+    refIsPortalCreated.current = true;
    return createPortal(/* … */);
  } else {
+    refIsPortalCreated.current = false;
    return null;
  }
Enter fullscreen mode Exit fullscreen mode

In the render function, we record whether it was possible to create the portal in refIsPortalCreated. The effect then runs afterward and calls forceUpdate if that was not the case.

A neat trick to create a function that forces a re-render is using useReducer. I found this on Stack Overflow some time ago.

Ok, first challenge completed!

đź«€The heart of the system

We have already created a ModalProvider and a context. Now we extend both to provide everything necessary to build the <Modal> component in the next step.

ModalProvider will keep track of all modals and determines the stacking order and which modal is currently active.

To achieve this, we’ll store information about all mounted modals in a tree like structure. The stacking order can then be determined by flattening this tree.

When a modal becomes active, all of its nodes (the modal itself and its descendants) are moved to the end of their respective subtrees, ensuring the active branch is on top of the stack.

The modal on the bottom of the tree, will be on top of the stack.

For example, when modal "A2a" becomes active…

App              Z
├─ A             1
│  ├─ A1         2
│  │  └─ A1a     3
│  ├─ A2         4
│  │  └─ A2a     5 (becomes active)
│  └─ A3         6
│     └─ A3a     7
├─ B             8
│  ├─ B1         9
│  │  └─ B1a     10
│  └─ B2         11
│     └─ B2a     12
├─ C             13
└─ D*            14 (is active)
Enter fullscreen mode Exit fullscreen mode

… the tree is reordered so that the active modal moves to the end of the flattened sequence:

App              Z
├─ B             1
│  ├─ B1         2
│  │  └─ B1a     3
│  └─ B2         4
│     └─ B2a     5
├─ C             6
├─ D             7
└▼ A             8
   ├─ A1         9
   │  └─ A1a     10
   ├─ A3         11
   │  └─ A3a     12
   └▼ A2         13
      └▼ A2a*    14 (is active)
Enter fullscreen mode Exit fullscreen mode

Later, when the active modal is closed, we walk upwards and select the the next open modal in the tree to become the active modal:

App              Z
├─ B             1
│  ├─ B1         2
│  │  └─ B1a     3
│  └─ B2         4
│     └─ B2a     5
├─ C             6
├─ D             7
└─ A             8
   ├─ A1         9
   │  └─ A1a     10
   ├─ A3         11
   │  └─ A3a     12
   └─ A2*        13 (is active)
      └─ A2a     14 (was closed)
Enter fullscreen mode Exit fullscreen mode

This example may be more complex than typical app modals, but it illustrates how we manage focus, and stacking order for hierarchical modals.

One thing to keep in mind when looking at this tree is that it shows all mounted <Modal> components and the <Modal> component will only render its content when open. This means that <Modal> will not have children when not open.

The modal tree

Calling it a tree is probably not the right term, because it does not have a root. It’s an array of modal nodes, each of which can have descendants. If I’m not mistaken, this is actually called a forest.

For each modal we will store two pieces of information:

  • A unique id
  • If the modal is open

And to manage this tree, we will need utility functions to:

  • Add a node
  • Remove a node
  • Update a node
  • Get a flat list of all nodes

When updating a node, we check whether it changed to open, and if so, we move the node and its ancestors to the end.

We start by creating a new file, modalTree.ts, where we define the data structure and implement the utility functions to work with the tree—uh, forest.

Damn it, I’ll just call it a tree.

// modalTree.ts
export type ModalTreeData = {
  id: string;
  isOpen: boolean;
};

export type ModalTreeNode = {
  data: ModalTreeData;
  children: ModalTreeNode[];
};

/**
 * Inserts a new modal or updates an existing modal in the forest.
 * If parentId is null, adds/updates the modal as a root node.
 * If parentId is provided, adds/updates the modal as a child of the parent.
 * Also moves the node (and ancestors) to the end if it changes to open state
 * Returns a new forest with the changes applied (immutable).
 */
export function upsertModalNode(
  modalNodes: ModalTreeNode[],
  modalData: ModalTreeData,
  parentId: string | null,
): ModalTreeNode[] {/* … */}

/**
 * Removes a modal from the forest by its ID.
 * If the modal has children, they are also removed (cascading delete).
 * Returns a new forest with the modal removed (immutable).
 */
export function removeModalNode(
  modalNodes: ModalTreeNode[],
  modalId: string,
): ModalTreeNode[] {/* … */}

/**
 * Flattens the forest into a single array of ModalTreeData in depth-first order.
 * For iterating over all modals and determining stack order.
 */
export function flattenModalNodes(
  modalNodes: ModalTreeNode[],
): ModalTreeData[] {/* … */}

Enter fullscreen mode Exit fullscreen mode

Manipulating trees is not part of this article, so I’ll spare you the details of the utility functions here. I’ve moved them to a gist where you can copy the code if needed: modalTree.ts

Inside ModalProvider, we keep a reference to an array of ModalTreeNode objects (the modal tree). Based on this tree, we determine both the id of the active modal and the stacking order, store them in state as activeModalId and modalOrder, and expose them through the context.

// ModalProvider.tsx
export function ModalProvider(/* … */) {
+  const modalsRef = useRef<ModalTreeNode[]>([]);
+  const [activeModalId, setActiveModalId] = useState<string | null>(null);
+  const [modalOrder, setModalOrder] = useState<string[]>([]);
Enter fullscreen mode Exit fullscreen mode

To keep the tree up to date, Modal components notify ModalProvider when they mount, unmount, or when their isOpen state changes. This is done by calling two functions: registerOrUpdateModal or unregisterModal, both of which are provided through the context.

Every time the modal tree is updated, we need to re-evaluate activeModalId and modalOrder. To do this, we create a helper function, update, which derives activeModalId and modalOrder from the current modal tree:

// ModalProvider.tsx
const update = useCallback(() => {
  const flatForest = flattenModalNodes(modalsRef.current);

  const openModals = flatForest.filter((m) => m.isOpen).map((m) => m.id);
  setActiveModalId(openModals[openModals.length - 1] ?? null);

  const newOrder = flatForest.map((m) => m.id);
  setModalOrder((prevOrder) => {
    if (
      prevOrder.length === newOrder.length &&
      prevOrder.every((id, index) => id === newOrder[index])
    ) {
      // only update order if it has changed
      return prevOrder;
    }
    return newOrder;
  });
}, []);
Enter fullscreen mode Exit fullscreen mode

Now we can implement registerOrUpdateModal and unregisterModal. By calling update() in both, we ensure that activeModalId and modalOrder stay in sync with the internal modal tree:

// ModalProvider.tsx
const registerOrUpdateModal = useCallback(
  (modal: ModalInfo) => {
    modalsRef.current = upsertModalNode(
      modalsRef.current,
      { id: modal.id, isOpen: modal.isOpen },
      modal.parentId,
    );
    update();
  },
  [update],
);

const unregisterModal = useCallback(
  (modalId: string) => {
    modalsRef.current = removeModalNode(modalsRef.current, modalId);
    update();
  },
  [update],
);
Enter fullscreen mode Exit fullscreen mode

Using useCallback for these functions is essential because we need stable references in order to use them safely in our Modal component.

Finally, we add activeModalId, modalOrder, registerOrUpdateModal, and unregisterModal to ModalContextType and and provide the relevant data via ModalProvider.

// context.ts
export type ModalInfo = {
  id: string;
  parentId: string | null;
  isOpen: boolean;
};

type ModalContextType = {
  activeModalId: string | null;
  modalOrder: string[];
  refModalContainer: RefObject<HTMLElement | null>;
  registerOrUpdateModal: (modal: ModalInfo) => void;
  unregisterModal: (id: string) => void;
} | null;
Enter fullscreen mode Exit fullscreen mode
// ModalProvider.tsx
const modalContextValue = {
+  activeModalId,
+  modalOrder,
  refModalContainer,
+  registerOrUpdateModal,
+  unregisterModal,
};
Enter fullscreen mode Exit fullscreen mode

The modal component

Now we can build the actual Modal component!

Modal will have three props isOpen, onCloseRequest, and children.

isOpen controls whether the modal is considered open.

onCloseRequest(reason: string) is a function that can be called from anywhere withing the modal to signal that it should be closed.
The component controlling isOpen decides how to handle this request, typically by setting isOpen to false. The reason parameter is useful for distinguishing between different close triggers and selectively ignoring them. For example, if we later add support for closing the modal when clicking outside, the reason could be "click-outside", allowing a specific modal instance to ignore that event and prevent accidental closure.

children represents the modal’s content and is only rendered when the modal is open.

For the Modal component, we once again extend ModalContext to provide modal-specific values that are useful deeper in the tree:

  • isOpen and onCloseRequest
  • isActive, which is true when the current modal is the active one.
  • level, which is initialized to 0 and incremented by 1 if a <Modal> is nested inside another <Modal>. For example, a level of 3 means the current modal has three ancestor modals.
  • The unique modal ID (modalId), generated using React’s useId().
// context.ts
type ModalContextType = {
  activeModalId: string | null;
  modalOrder: string[];
  refModalContainer: RefObject<HTMLElement | null>;
  registerOrUpdateModal: (modal: ModalInfo) => void;
  unregisterModal: (id: string) => void;
+  isOpen: boolean | null;
+  onCloseRequest: ((reason: string) => void) | null;
+  isActive: boolean | null;
+  level: number | null;
+  modalId: string | null;
} | null;
Enter fullscreen mode Exit fullscreen mode

In ModalProvider, we set these fields to null initially:

// ModalProvider.tsx
const modalContextValue = {
  activeModalId,
  modalOrder,
  refModalContainer,
  registerOrUpdateModal,
  unregisterModal,
+  // Modal-specific fields are initialized to null
+  isOpen: null,
+  onCloseRequest: null,
+  isActive: null,
+  level: null,
+  modalId: null,
};
Enter fullscreen mode Exit fullscreen mode

Next, we create a hook that abstracts all the logic related to registering, unregistering, and updating modal-specific values in the context. The hook returns the updated context value, which we then pass to a provider in Modal for consumers deeper in the tree.

// useModal.ts
export type UseModalArgs = {
  isOpen: boolean;
  onCloseRequest: (reason: string) => void;
};

export function useModal({ isOpen, onCloseRequest }: UseModalArgs) {
  const modalContext = useModalContext();

  const modalId = useId();
  const isActive = modalContext.activeModalId === modalId;

  const level = modalContext.level === null ? 0 : modalContext.level + 1;

  const {
    registerOrUpdateModal,
    unregisterModal,
    modalId: parentModalId,
  } = modalContext;

  // useLayoutEffect is used here to block the browser from
  // repainting the screen before the modal is registered
  useLayoutEffect(() => {
    registerOrUpdateModal({
      id: modalId,
      parentId: parentModalId,
      isOpen,
    });
  }, [registerOrUpdateModal, modalId, parentModalId, isOpen]);

  useLayoutEffect(() => {
    return () => {
      unregisterModal(modalId);
    };
  }, [unregisterModal, modalId]);

  const contextValue = {
    ...modalContext,
    isOpen,
    isActive,
    level,
    modalId,
    onCloseRequest,
  };

  return {
    contextValue,
  };
}
Enter fullscreen mode Exit fullscreen mode

Finally, in the Modal component, we use ModalPortal to render the actual HTML into the modal container. The modals children are rendered only when the modal is open. For testing and demonstration purposes, we also add a simple "Close" button to close the modal.

Note how modalOrder is used to set a CSS custom property for the z-index. While this has no effect yet, we will use it in the CSS to ensure that the visual stacking of modals stays in sync with the defined modal order. This is necessary because the rendered HTML inside the modal container does not reflect the modal order we defined.

// Modal/index.tsx
import styles from "./styles.module.css";

type ModalProps = {
  isOpen: boolean;
  onCloseRequest: (reason: string) => void;
  children: React.ReactNode;
};

export function Modal({ isOpen, onCloseRequest, children }: ModalProps) {
  const { contextValue } = useModal({ isOpen, onCloseRequest });

  const { modalId, modalOrder } = contextValue;

  return (
    <ModalContext.Provider value={contextValue}>
      <ModalPortal>
        <div
          id={modalId}
          style={
            {
              "--modal-z-index": modalOrder.indexOf(modalId) + 1,
            } as React.CSSProperties
          }
        >
          {isOpen && (
            <div className={styles.modalWrapper}>
              <div className={styles.modalWindow}>
                {children}
                <div>
                  <button onClick={() => onCloseRequest?.("close-button")}>
                    Close
                  </button>
                </div>
              </div>
            </div>
          )}
        </div>
      </ModalPortal>
    </ModalContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Styling the modal

Last but not least, we add some CSS to style the Modal.

As mentioned earlier, we use --modal-z-index to control the z-index of our modals. Before doing so, we need to ensure that the modals live in their own stacking context.

To achieve this, we assign an explicit z-index value to the modal container. Using a value of 0 creates a new stacking context while preserving the normal document order.

// App.tsx
-      <div ref={refModalContainer} />
+      <div ref={refModalContainer} style={{ zIndex: 0, position: "relative" }} />
Enter fullscreen mode Exit fullscreen mode

Because the modal container appears after the rest of the app in the DOM, a z-index of 0 still places the modals above everything else—unless another element explicitly sets a higher z-index (i.e., greater than 0). In that case, that element would appear above the modals. For this reason, it's generally a good idea to also wrap the rest of the app in its own stacking context.

All that's left now is to set z-index for .modalWrapper:

.modalWrapper {
  position: fixed;
  z-index: var(--modal-z-index);
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  overflow: auto;
  display: flex;
  align-items: flex-start;
  background-color: rgba(0, 0, 0, 0.32);
  padding: 0.5rem;
}
Enter fullscreen mode Exit fullscreen mode

The .modalWrapper element acts as the backdrop. It uses position: fixed and spans the entire viewport. We enable scrolling with overflow: auto; in case the modal window exceeds the available space. Using display: flex allows us to easily position the modal window within.

The styles of the .modalWindow are mostly a matter of taste; this is what I came up with for a simple modal window:

.modalWindow {
  position: relative;
  box-sizing: border-box;
  margin: 3rem auto 0;
  padding: 1rem;
  max-width: 100%;
  border-radius: 1rem;
  background: white;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
Enter fullscreen mode Exit fullscreen mode

Alright, that's it for the first part. Now let's test the modal system.

Using the Modal component

First you can remove the ModalPortal from App()–if you haven't already–and add a state variable to control the new Modal component:

function App() {
/* … */
+  const [isModalOpen, setIsModalOpen] = useState(false);
/* … */
      <ModalProvider refModalContainer={refModalContainer}>
        {/* … */}
-        <ModalPortal /* … */ />
+        <button onClick={() => setIsModalOpen(true)}>Open Modal</button>
+        <Modal
+          isOpen={isModalOpen}
+          onCloseRequest={() => setIsModalOpen(false)}
+        >
+          Hello, Modal!
+        </Modal>
      </ModalProvider>
      <div ref={refModalContainer} />
/* … */
Enter fullscreen mode Exit fullscreen mode

After clicking on "Open Modal" this is what you should see:

An open modal dialog with a close button

We now have a working basic modal dialog, nice! 🥳

Now let’s try a more advanced experiment to test nested modals. We’ll create a component that serves as the modal's content and includes another modal that can be opened. We then use this component recursively as the modal content. This should allow us to open an infinite chain of nested modals. If that works, we know nested modals are functioning correctly. 🙂

function ModalContent() {
  const [isNestedModalOpen, setIsNestedModalOpen] = useState(false);
  const { level } = useModalContext();

  return (
    <div>
      <p>Modal level: {level}.</p>
      <button onClick={() => setIsNestedModalOpen(true)}>
        Open a nested modal
      </button>
      <Modal
        isOpen={isNestedModalOpen}
        onCloseRequest={() => setIsNestedModalOpen(false)}
      >
        <ModalContent />
      </Modal>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Inside ModalContent, we can consume level from ModalContext and display it in the modal so we can see which level we’re currently on.

Now, use ModalContent instead of "Hello, Modal!":

      <Modal isOpen={isModalOpen} onCloseRequest={() => setIsModalOpen(false)}>
-        Hello, Modal!
+        <ModalContent />
      </Modal>
Enter fullscreen mode Exit fullscreen mode

5 nested modals on top of each other

Yay, this works.

Next up, we’ll dig into some important aspects to improve the usability and accessibility of the modals:

  • How to trap focus inside a modal
  • How to close modals via the Escape key and by clicking outside
  • What to consider to meet modern accessibility guidelines

Thanks for reading!

I hope you had fun and gained some useful insights from this part of the guide on building modal dialogs in React.

If you did not follow along or something wasn't that clear, you can find the full source code with working examples on my GitHub Profile.

https://github.com/receter/react-modal-dialog/tree/part1
(Branch: part1)

I'm all ears for any thoughts you'd like to share.

Top comments (0)