What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

Use React state to control whether a modal is open, and use createPortal when the dialog needs to escape a parent’s clipping or stacking styles. A portal only changes where the content appears in the DOM; it does not add keyboard handling or make a dialog accessible. The example below provides basic open, close, Escape, backdrop, and focus behavior, then explains what you must address before treating a modal as production-ready.

Build a React modal with a portal

This example renders the dialog into document.body. It returns focus to the opener on close, moves focus into the dialog when it opens, keeps Tab navigation within the dialog, and closes on Escape or a click on the backdrop. Those behaviors are a starting point, not a complete modal system: in particular, the surrounding page must actually be unavailable to interaction while the modal is open before aria-modal="true" accurately describes the experience.

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

const FOCUSABLE = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(',');

function Modal({ titleId, onClose, children }) {
  const dialogRef = useRef(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    const previousFocus = document.activeElement;
    const focusable = () => [...dialog.querySelectorAll(FOCUSABLE)];
    const first = focusable()[0];
    (first || dialog).focus();

    function onKeyDown(event) {
      if (event.key === 'Escape') {
        event.preventDefault();
        onClose();
        return;
      }
      if (event.key !== 'Tab') return;

      const items = focusable();
      if (items.length === 0) {
        event.preventDefault();
        dialog.focus();
        return;
      }
      const firstItem = items[0];
      const lastItem = items[items.length - 1];
      if (event.shiftKey && (document.activeElement === firstItem || document.activeElement === dialog)) {
        event.preventDefault();
        lastItem.focus();
      } else if (!event.shiftKey && document.activeElement === lastItem) {
        event.preventDefault();
        firstItem.focus();
      }
    }

    document.addEventListener('keydown', onKeyDown);
    return () => {
      document.removeEventListener('keydown', onKeyDown);
      if (previousFocus instanceof HTMLElement) previousFocus.focus();
    };
  }, [onClose]);

  return createPortal(
    <div
      className="modal-backdrop"
      onMouseDown={event => {
        if (event.target === event.currentTarget) onClose();
      }}
    >
      <section
        ref={dialogRef}
        className="modal-dialog"
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        tabIndex={-1}
      >
        {children}
        <button type="button" onClick={onClose}>Close</button>
      </section>
    </div>,
    document.body
  );
}

export default function App() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>Open dialog</button>
      {open && (
        <Modal titleId="example-modal-title" onClose={() => setOpen(false)}>
          <h2 id="example-modal-title">Example dialog</h2>
          <p>Dialog content goes here.</p>
        </Modal>
      )}
    </>
  );
}

The component uses useState, so add it to the import at the top of the file:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import { useEffect, useRef, useState } from 'react';

The backdrop handler compares the event target with the backdrop itself. As a result, clicking dialog content does not dismiss the modal. If backdrop dismissal is not appropriate for your interface, remove the handler and require users to close the dialog with its close button or another explicit action.

Add basic overlay styles

These styles place the overlay above ordinary page content and center the dialog. Adjust the sizing and colors to suit the application; stacking contexts created elsewhere in the page may require additional CSS or a different portal target.

.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: grid;
  place-items: center;
  padding: 1rem;
  background: rgb(0 0 0 / 0.55);
}

.modal-dialog {
  width: min(100%, 32rem);
  max-height: calc(100vh - 2rem);
  overflow: auto;
  padding: 1.5rem;
  border-radius: 0.5rem;
  background: white;
  color: #111;
}

Choose inline rendering or a portal

React’s createPortal reference describes a portal as rendering children into an existing DOM node while preserving their place in the React tree. Use the simplest rendering position that works for the page.

Approach When it fits What to account for
Render inline The modal’s ancestors do not clip it or constrain its overlay placement. Ancestor layout, overflow, and stacking styles can affect the overlay.
Render through a portal The dialog needs to escape an ancestor that clips or constrains it, such as one using overflow: hidden. The target DOM node must exist when rendering. React-tree context remains available, and events can bubble to React ancestors.

A portal changes physical DOM placement, not React ownership. For example, a click inside portal content can still reach a click handler on a React ancestor. Stop or otherwise handle propagation when that behavior would cause an unwanted action.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

What makes a modal accessible

The WAI-ARIA Authoring Practices dialog pattern describes modal interaction as a separate context: keyboard focus remains within the dialog, users can dismiss it with Escape in the pattern, and focus returns to the invoking element when it closes. The example implements basic focus entry, Tab cycling, Escape, and focus restoration, but a selector-based focus trap is not a substitute for testing all content and browser behaviors.

  • Name the dialog: Give it a visible heading and connect the heading with aria-labelledby, as in the example.
  • Make modality true in behavior: aria-modal="true" tells assistive technology that the underlying content is unavailable. It does not itself disable background controls or prevent keyboard and pointer interaction with the page. Implement that behavior in the application before using the attribute to claim the dialog is modal. The W3C modal dialog example explains the attribute’s purpose.
  • Keep the dialog exposed: Do not apply aria-hidden to an ancestor that also contains the dialog; that would hide the dialog from assistive technology too.
  • Test real interaction: Check opening, focus placement, forward and reverse Tab, Escape, close-button behavior, focus restoration, and whether background content is unavailable. Test with the assistive technologies and browsers the application supports.

Production interfaces may also need scroll locking, coordination between stacked dialogs, and a more comprehensive focus-management implementation. Account for those requirements rather than treating a portal or a few ARIA attributes as a complete accessibility solution.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.