keepcalculator

06 Numbers and Powers

React Modal Component: Build a Dialog That Works

Card No. 06·53901 · Updated · Standard formula, shown below

Minutes 2.00 min
Time (seconds)
120 s
You entered
You enter
Time (seconds)
You get
Minutes
Rounding
2 dp

Yes, you can build a React modal component that works reliably by controlling it with state, rendering it in a portal, and handling focus, escape, and click-outside behavior. This article walks through a complete example, explains the core concepts, and covers common pitfalls so your dialog is accessible and robust.

Why Modals Are Tricky in React

Modals are a common UI pattern, but they’re easy to get wrong. The main challenges are:

  • Rendering location: If you render a modal inside a deeply nested component, its stacking context and overflow properties can clip it. Using a React portal to render it into document.body avoids this.
  • Accessibility: Users on screen readers need the modal to be announced, focus to be trapped inside, and the rest of the page to be inert. Ignoring this makes the dialog unusable for some people.
  • State management: The modal’s open/closed state lives in the parent component. You need a clean way to open, close, and pass data.

Core Concepts: Portal, State, and Events

A modal component typically relies on three React features:

  • State: A boolean like isOpen in the parent controls whether the modal renders.
  • Portal: ReactDOM.createPortal(children, document.body) renders the modal outside the parent DOM tree, avoiding CSS clipping.
  • Event handlers: You attach onClick to a backdrop and onKeyDown for Escape to close the modal.

Here’s a minimal but functional example:

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;
  return ReactDOM.createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={e => e.stopPropagation()} role="dialog" aria-modal="true">
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
}

This works for simple cases, but production dialogs need more: focus management, scroll locking, and proper ARIA attributes.

Building a Complete Example

Let’s build a modal that meets common requirements. We’ll use React hooks for state and effects.

  1. Setup state: In the parent, const [isOpen, setIsOpen] = useState(false).
  2. Create the modal component that accepts isOpen, onClose, and children.
  3. Use a portal to render into document.body.
  4. Add an effect to listen for the Escape key and to lock body scroll.
  5. Focus the first focusable element when the modal opens, and restore focus to the trigger button when it closes.

Here’s a more complete implementation:

import { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';

function Modal({ isOpen, onClose, title, children }) {
  const modalRef = useRef(null);

  useEffect(() => {
    if (!isOpen) return;
    const previouslyFocused = document.activeElement;
    modalRef.current?.focus();
    const handleKey = (e) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', handleKey);
    document.body.style.overflow = 'hidden';
    return () => {
      document.removeEventListener('keydown', handleKey);
      document.body.style.overflow = '';
      previouslyFocused?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;
  return ReactDOM.createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div
        className="modal-content"
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        ref={modalRef}
        onClick={e => e.stopPropagation()}
      >
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
}

This handles the basics. For full accessibility, you’d also implement focus trapping (keeping Tab within the modal) and an aria-describedby for the description.

Focus Trapping and Accessibility

Focus trapping ensures that keyboard users can’t Tab out of the modal into the page behind. Without it, they might lose context. A simple approach is to query all focusable elements inside the modal and cycle focus on Tab and Shift+Tab.

const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function handleTab(e) {
  if (e.key !== 'Tab') return;
  const focusables = modalRef.current.querySelectorAll(focusableSelector);
  const first = focusables[0];
  const last = focusables[focusables.length - 1];
  if (e.shiftKey && document.activeElement === first) {
    e.preventDefault();
    last.focus();
  } else if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault();
    first.focus();
  }
}

Add this to the keydown listener. Also, set aria-modal="true" and role="dialog" so screen readers treat it as a dialog. Use aria-labelledby to point to the title.

Common Mistakes and How to Fix Them

  • Not using a portal: The modal gets clipped by an ancestor with overflow: hidden. Fix: render via portal.
  • Click-outside closing when clicking inside: The backdrop’s onClick fires even when you click the content. Fix: call e.stopPropagation() on the content.
  • Escape key not working: You forgot to attach the listener or it’s removed too early. Fix: add in a useEffect with cleanup.
  • Body scroll not locked: The page scrolls behind the modal. Fix: set document.body.style.overflow = 'hidden' on open and restore on close.
  • Focus not trapped: Users can Tab out. Fix: implement the keydown handler above.

When to Use a Library vs. Build Your Own

If your app needs a simple confirm dialog, building your own is fine. But if you need complex features like nested modals, animations, or drag-and-drop, a library like React Modal or Headless UI can save time. Libraries are battle-tested for accessibility. However, they add bundle size and a learning curve. For a single dialog, a custom component is often lighter and more educational.

Frequently Asked Questions

Why does my modal appear behind other elements?

This is usually a stacking context issue. If an ancestor has a transform, filter, or z-index, it creates a new stacking context, and the modal can’t escape it. Using a portal to document.body avoids this, but ensure the body has no conflicting z-index.

How do I prevent the modal from closing when clicking inside?

Add onClick={(e) => e.stopPropagation()} to the modal content div. This stops the event from bubbling to the overlay, which has the close handler.

Is it safe to use document.body.style.overflow?

Yes, but remember to restore the original value when the modal closes. If you have multiple modals open, you need a counter or a utility that tracks the number of open modals to avoid prematurely unlocking scroll.

How do I animate the modal with CSS?

You can add CSS transitions to the overlay and content. However, because the component returns null when closed, you need to keep it mounted during the exit animation. Use a state like isClosing to delay unmounting, or use a library like framer-motion.

What is the difference between role="dialog" and role="alertdialog"?

Use role="dialog" for general dialogs. Use role="alertdialog" only when the dialog requires an immediate response, like a confirmation that an action will be destructive. Screen readers may announce alertdialogs differently.

Can I use a modal inside a form?

Yes, but be careful with nested forms. HTML doesn’t allow nested <form> elements. If your modal contains a form, it should be outside the main form or use the form attribute to associate it.

Related

Formula used
in t | Time (seconds) | s | 120 | 0 | 3600
out minutes | Minutes | min | t / 60 | 2

Each line is one input (in, sel, list) or one result (out: label, unit, formula, decimals). How the calculators work

Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.

More cards in this drawer

The whole Numbers and Powers drawer →