← Back

React.js + Next.js · Module 3. Forms and Effects · Lesson 13 · Point 13

React Modal Windows, Portals, and Children

React + TypeScript · GoIT Dev Notes

This lesson builds on effect cleanup by creating a reusable modal with CSS Modules, createPortal, callback props, conditional rendering, keyboard events, scroll locking, and children.

You will learn how a parent controls a reusable child, how to close a modal safely, why cleanup matters for global listeners, and how one modal can display different content.


REACT · 01

1. What Is a Modal?

A modal is a dialog that appears above the main page content. It is commonly used to confirm deletion, show a contact form, display an image, or announce a successful action.

Start with an ordinary component. At this stage it appears in the normal page layout.

// src/components/Modal.tsx
export default function Modal() {
  return (
    <div>
      <h2>Modal Title</h2>
      <p>This is my first modal.</p>
    </div>
  );
}

// src/components/App.tsx
import Modal from "./Modal";

export default function App() {
  return (
    <div>
      <h1>My Website</h1>
      <Modal />
    </div>
  );
}

Browser Result

localhost:5173

My Website

The content is still part of the ordinary document flow.

REACT · 02

2. Style the Backdrop and Dialog

The backdrop is the dark layer covering the screen. The dialog is the white content panel.

/* src/components/Modal.module.css */
.backdrop {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: rgba(0, 0, 0, 0.6);
}

.modal {
  position: relative;
  width: min(90vw, 520px);
  max-height: 90vh;
  overflow: auto;
  padding: 24px;
  border-radius: 8px;
  background-color: white;
  color: #222;
}

.closeButton {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 32px;
  height: 32px;
  border: 0;
  border-radius: 4px;
  background: white;
  color: #333;
  cursor: pointer;
  font-size: 20px;
}

.closeButton:hover { background: #f2f2f2; }
.closeButton:focus-visible { outline: 2px solid #007bff; }

position: fixed attaches the backdrop to the viewport. inset: 0 is shorthand for setting all four edges to zero. Flexbox centers the panel.

Import a CSS Module and use its generated class names:

import css from "./Modal.module.css";

export default function Modal() {
  return (
    <div className={css.backdrop}>
      <div className={css.modal}>Hello!</div>
    </div>
  );
}

Browser Result

localhost:5173
The preview represents the fixed backdrop and centered dialog.

REACT · 03

3. Render a Modal with createPortal

A portal lets React render JSX into a different DOM container while the component remains in the same React tree.

import { createPortal } from "react-dom";
import css from "./Modal.module.css";

export default function Modal() {
  return createPortal(
    <div className={css.backdrop}>
      <div className={css.modal}>Hello!</div>
    </div>,
    document.body
  );
}

createPortal(content, container) receives the JSX to render and the DOM element that should contain it. Here, the modal HTML becomes a child of document.body.

Portals help avoid ancestor layout problems such as clipping by overflow: hidden. They do not automatically provide focus management, keyboard behavior, or accessibility.

REACT · 04

4. Pass a Close Function Through Props

The parent owns the open state. The modal receives a callback that asks the parent to close it.

interface ModalProps {
  onClose: () => void;
}

export default function Modal(props: ModalProps) {
  return (
    <button onClick={props.onClose} aria-label="Close modal">
      ×
    </button>
  );
}

onClose: () => void describes a function that receives no arguments and returns no useful value.

CodeMeaning
onClick={props.onClose}Pass the callback for React to call later.
onClick={props.onClose()}Calls the function immediately during rendering and is wrong here.

REACT · 05

5. Control Visibility with useState

import { useState } from "react";
import Modal from "./Modal";

export default function App() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  function openModal() {
    setIsModalOpen(true);
  }

  function closeModal() {
    setIsModalOpen(false);
  }

  return (
    <div>
      <button onClick={openModal}>Open Modal</button>
      {isModalOpen && <Modal onClose={closeModal} />}
    </div>
  );
}

When the state is false, React does not render the modal. When it is true, React mounts it. Setting the value back to false unmounts the modal.

The expression {isModalOpen && <Modal ... />} is conditional rendering.

Browser Result

localhost:5173
The button changes the state; the state decides whether the modal exists.

REACT · 06

6. Close When the Backdrop Is Clicked

The modal should close when the dark backdrop is clicked, but not when the user clicks inside the white panel.

import type { MouseEvent } from "react";

function handleBackdropClick(event: MouseEvent<HTMLDivElement>) {
  if (event.target === event.currentTarget) {
    props.onClose();
  }
}
  • event.target is the element that was actually clicked.
  • event.currentTarget is the element that owns this event handler.

If they are equal, the user clicked the backdrop itself. Without this check, a click inside the panel can bubble to the backdrop handler and close the modal.

REACT · 07

7. Close with Escape Using useEffect

A document-level keyboard listener is an external subscription, so it belongs in an Effect and needs cleanup.

useEffect(function () {
  function handleKeyDown(event: KeyboardEvent) {
    if (event.key === "Escape") {
      props.onClose();
    }
  }

  document.addEventListener("keydown", handleKeyDown);

  return function () {
    document.removeEventListener("keydown", handleKeyDown);
  };
}, [props.onClose]);

The cleanup removes the exact listener added by the Effect. It runs before the Effect is refreshed and when the modal unmounts.

The dependency [props.onClose] keeps the listener synchronized if the callback reference changes.

REACT · 08

8. Lock Background Scrolling

The page behind an open modal should normally stay still. Save the existing inline value before changing it, then restore that value during cleanup.

useEffect(function () {
  const previousOverflow = document.body.style.overflow;
  document.body.style.overflow = "hidden";

  return function () {
    document.body.style.overflow = previousOverflow;
  };
}, []);

Changing document.body is a side effect. Restoring the previous value is safer than assuming it was originally an empty string.

REACT · 09

9. Make the Modal Reusable with children

Everything placed between a component's opening and closing tags is passed through the special children prop.

<Modal onClose={closeModal}>
  <h2>Delete product</h2>
  <p>Are you sure?</p>
</Modal>

Type reusable renderable content with ReactNode:

import type { ReactNode } from "react";

interface ModalProps {
  onClose: () => void;
  children: ReactNode;
}

export default function Modal(props: ModalProps) {
  return (
    <div>
      <button onClick={props.onClose}>Close</button>
      {props.children}
    </div>
  );
}

ReactNode accepts text, numbers, JSX elements, fragments, multiple elements, and other renderable React content. A type such as string would be too restrictive.

REACT · 10

10. Choose Different Messages with One Modal

One state can choose the content while another state controls visibility.

const [message, setMessage] = useState("delete");
const [isModalOpen, setIsModalOpen] = useState(false);
StateQuestion it answers
messageWhat content should appear?
isModalOpenShould the modal appear?
<Modal onClose={closeModal}>
  {message === "delete" && <p>Product deleted!</p>}
  {message === "success" && <p>Order completed!</p>}
</Modal>

The modal structure stays the same. Only its children change.

REACT · 11

11. Accessibility Basics

<div
  className={css.modal}
  role="dialog"
  aria-modal="true"
  aria-label="Notification"
>
  • role="dialog" identifies the element as a dialog.
  • aria-modal="true" declares that interaction is modal.
  • aria-label provides the dialog with an accessible name.
  • An icon-only close button needs a name such as aria-label="Close modal".

REACT · 12

12. Complete Learning Example

This example combines the portal, callback prop, children, backdrop handling, Escape listener, scroll lock, conditional rendering, and cleanup.

Modal.module.css

.backdrop {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: rgba(0, 0, 0, 0.6);
}

.modal {
  position: relative;
  width: min(90vw, 520px);
  max-height: 90vh;
  overflow: auto;
  padding: 24px;
  border-radius: 8px;
  background-color: white;
  color: #222;
}

.closeButton {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 32px;
  height: 32px;
  border: 0;
  border-radius: 4px;
  background: white;
  color: #333;
  cursor: pointer;
  font-size: 20px;
}

Modal.tsx

import { useEffect } from "react";
import type { MouseEvent, ReactNode } from "react";
import { createPortal } from "react-dom";
import css from "./Modal.module.css";

interface ModalProps {
  onClose: () => void;
  children: ReactNode;
}

export default function Modal(props: ModalProps) {
  function handleBackdropClick(event: MouseEvent<HTMLDivElement>) {
    if (event.target === event.currentTarget) {
      props.onClose();
    }
  }

  useEffect(function () {
    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        props.onClose();
      }
    }

    const previousOverflow = document.body.style.overflow;
    document.addEventListener("keydown", handleKeyDown);
    document.body.style.overflow = "hidden";

    return function () {
      document.removeEventListener("keydown", handleKeyDown);
      document.body.style.overflow = previousOverflow;
    };
  }, [props.onClose]);

  return createPortal(
    <div className={css.backdrop} onClick={handleBackdropClick}>
      <div
        className={css.modal}
        role="dialog"
        aria-modal="true"
        aria-label="Notification"
      >
        <button
          className={css.closeButton}
          onClick={props.onClose}
          aria-label="Close modal"
        >
          ×
        </button>

        {props.children}
      </div>
    </div>,
    document.body
  );
}

App.tsx

import { useState } from "react";
import Modal from "./Modal";

type Message = "delete" | "success";

export default function App() {
  const [message, setMessage] = useState<Message>("delete");
  const [isModalOpen, setIsModalOpen] = useState(false);

  function showDelete() {
    setMessage("delete");
    setIsModalOpen(true);
  }

  function showSuccess() {
    setMessage("success");
    setIsModalOpen(true);
  }

  function closeModal() {
    setIsModalOpen(false);
  }

  return (
    <div>
      <h1>My Website</h1>
      <button onClick={showDelete}>Show delete</button>
      <button onClick={showSuccess}>Show success</button>

      {isModalOpen && (
        <Modal onClose={closeModal}>
          {message === "delete" && (
            <>
              <h2>Delete product</h2>
              <p>Product deleted!</p>
            </>
          )}

          {message === "success" && (
            <>
              <h2>Order completed</h2>
              <p>Thank you for your purchase!</p>
            </>
          )}
        </Modal>
      )}
    </div>
  );
}

Browser Result

localhost:5173
The same modal displays different children and can close from the button, backdrop, or Escape key.

What Happens When the User Clicks?

  1. The user clicks Show success.
  2. showSuccess() selects the success message and opens the modal.
  3. The success JSX becomes props.children.
  4. createPortal places the modal HTML under document.body.
  5. The Effect adds the Escape listener and locks scrolling.
  6. The user closes the modal.
  7. React unmounts it, removes the listener, and restores scrolling.

REACT · 13

13. Common Mistakes and Fixes

MistakeCorrect approach
Using onClick={closeModal()}Pass the callback with onClick={closeModal}.
Closing for every bubbled backdrop clickCompare event.target and event.currentTarget.
Adding a listener without removing itReturn removeEventListener from the Effect.
Leaving body scrolling lockedRestore the previous overflow value during cleanup.
Hardcoding every message inside ModalPass customizable content through children.
Using one state for two different questionsKeep content selection separate from visibility.
Assuming a portal makes the modal accessibleAdd labeling, focus behavior, and background interaction handling.

REACT · 14

14. Practical Exercise

  1. Create src/components/Modal.module.css and add the backdrop, modal, and close-button styles.
  2. Create src/components/Modal.tsx with the portal, props, children, backdrop handler, and Effect cleanup.
  3. Update src/components/App.tsx with the two message buttons.
  4. Run npm run dev and open the local URL.
  5. Verify that the close button, dark backdrop, and Escape key all close the modal.
  6. Click inside the white panel and verify that it stays open.
  7. Add long page content and confirm that background scrolling is locked only while the modal is open.

Project Structure

src/
  components/
    App.tsx
    Modal.tsx
    Modal.module.css
  main.tsx

Tiny Challenge: Add a Third Message

type Message = "delete" | "success" | "contact";

function showContact() {
  setMessage("contact");
  setIsModalOpen(true);
}

<button onClick={showContact}>Contact</button>

{message === "contact" && (
  <>
    <h2>Contact us</h2>
    <p>Send us a message!</p>
  </>
)}

Self-check: Why are message and isModalOpen separate? Why does a click inside the dialog not close it? What work does the Effect cleanup perform?

REACT · 15

15. Quick Review

  • A modal overlays the normal page content.
  • CSS Modules style the backdrop and dialog panel.
  • createPortal renders JSX into another DOM container.
  • The parent owns the open state; the modal receives onClose.
  • event.target === event.currentTarget detects a direct backdrop click.
  • An Effect registers the Escape listener and cleans it up.
  • Cleanup also restores the page's previous scroll behavior.
  • children: ReactNode makes one modal reusable for different content.
  • Accessibility requires more than a portal and a few ARIA attributes.

Main idea: the reusable modal owns the shared dialog behavior, while its parent owns visibility and chooses the content.

← Back↑ Back to the top