Skip to content
Codeloom
React

React Portals and Modal Patterns

Build accessible modals, tooltips, and dropdowns with React Portals. Learn focus trapping, keyboard handling, and reusable modal patterns.

·7 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • How React Portals render outside the DOM hierarchy
  • Building accessible modals with focus trapping
  • Keyboard handling and scroll locking
  • Reusable modal patterns with compound components

Prerequisites

  • React components and hooks
  • Basic DOM knowledge (event bubbling, z-index)

React Portals let you render children into a DOM node outside the parent component’s hierarchy. This solves a fundamental problem: overlays like modals, tooltips, and dropdowns often get clipped by ancestor elements with overflow: hidden or wrong z-index stacking contexts.

The Problem Portals Solve

Imagine a modal inside a card component:

function Card() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div style={{ overflow: 'hidden', position: 'relative' }}>
      <h3>Card Title</h3>
      <button onClick={() => setShowModal(true)}>Open Details</button>
      {showModal && (
        <div className="modal-overlay">
          {/* This modal gets clipped by the parent's overflow: hidden */}
          <div className="modal">Modal content</div>
        </div>
      )}
    </div>
  );
}

The modal renders inside the card’s DOM, so overflow: hidden clips it. Portals fix this by rendering the modal at the end of document.body.

Creating a Portal

Use createPortal from react-dom:

import { createPortal } from 'react-dom';

function Portal({ children }: { children: React.ReactNode }) {
  return createPortal(children, document.body);
}

function Card() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div style={{ overflow: 'hidden' }}>
      <h3>Card Title</h3>
      <button onClick={() => setShowModal(true)}>Open Details</button>
      {showModal && (
        <Portal>
          <div className="modal-overlay">
            <div className="modal">
              <p>This renders at document.body, outside the card</p>
              <button onClick={() => setShowModal(false)}>Close</button>
            </div>
          </div>
        </Portal>
      )}
    </div>
  );
}

Even though the modal renders in document.body in the DOM, it still behaves as a child of Card in React’s tree. Events bubble through the React tree, not the DOM tree. Clicking inside the portal still triggers React event handlers on parent components.

Building an Accessible Modal

A proper modal needs more than just a portal. It needs focus trapping, keyboard handling, scroll locking, and ARIA attributes:

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

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousActiveElement = useRef<HTMLElement | null>(null);

  // Lock body scroll
  useEffect(() => {
    if (isOpen) {
      previousActiveElement.current = document.activeElement as HTMLElement;
      document.body.style.overflow = 'hidden';

      return () => {
        document.body.style.overflow = '';
        previousActiveElement.current?.focus();
      };
    }
  }, [isOpen]);

  // Focus trap
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose();
        return;
      }

      if (e.key === 'Tab') {
        const modal = modalRef.current;
        if (!modal) return;

        const focusableElements = modal.querySelectorAll<HTMLElement>(
          'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
        );
        const firstElement = focusableElements[0];
        const lastElement = focusableElements[focusableElements.length - 1];

        if (e.shiftKey) {
          if (document.activeElement === firstElement) {
            e.preventDefault();
            lastElement.focus();
          }
        } else {
          if (document.activeElement === lastElement) {
            e.preventDefault();
            firstElement.focus();
          }
        }
      }
    },
    [onClose]
  );

  // Auto-focus modal on open
  useEffect(() => {
    if (isOpen && modalRef.current) {
      const firstFocusable = modalRef.current.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return createPortal(
    <div
      className="modal-overlay"
      onClick={onClose}
      role="presentation"
    >
      <div
        ref={modalRef}
        className="modal"
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        onClick={e => e.stopPropagation()}
        onKeyDown={handleKeyDown}
      >
        <header className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close modal"
            className="modal-close"
          >
            &times;
          </button>
        </header>
        <div className="modal-body">{children}</div>
      </div>
    </div>,
    document.body
  );
}

This modal handles:

  • Focus trapping: Tab and Shift+Tab cycle through focusable elements inside the modal.
  • Escape key: Pressing Escape closes the modal.
  • Scroll locking: Body scroll is disabled while the modal is open.
  • Focus restoration: When the modal closes, focus returns to the element that opened it.
  • ARIA attributes: role="dialog", aria-modal="true", and aria-labelledby make it accessible to screen readers.
  • Overlay click: Clicking outside the modal closes it.

Using the Modal

function ProductPage() {
  const [showConfirm, setShowConfirm] = useState(false);

  return (
    <div>
      <h1>Product Details</h1>
      <button onClick={() => setShowConfirm(true)}>Delete Product</button>

      <Modal
        isOpen={showConfirm}
        onClose={() => setShowConfirm(false)}
        title="Confirm Deletion"
      >
        <p>Are you sure you want to delete this product? This action cannot be undone.</p>
        <div className="modal-actions">
          <button onClick={() => setShowConfirm(false)}>Cancel</button>
          <button
            onClick={() => {
              deleteProduct();
              setShowConfirm(false);
            }}
            className="danger"
          >
            Delete
          </button>
        </div>
      </Modal>
    </div>
  );
}

Compound Component Modal Pattern

For more flexibility, use a compound component pattern that lets consumers compose the modal structure:

import { createContext, useContext } from 'react';

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

const ModalContext = createContext<ModalContextValue | null>(null);

function useModalContext() {
  const context = useContext(ModalContext);
  if (!context) throw new Error('Modal components must be used within Modal');
  return context;
}

function ModalRoot({ isOpen, onClose, children }: {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}) {
  if (!isOpen) return null;

  return createPortal(
    <ModalContext.Provider value={{ onClose }}>
      <div className="modal-overlay" onClick={onClose} role="presentation">
        <div
          className="modal"
          role="dialog"
          aria-modal="true"
          onClick={e => e.stopPropagation()}
        >
          {children}
        </div>
      </div>
    </ModalContext.Provider>,
    document.body
  );
}

function ModalHeader({ children }: { children: React.ReactNode }) {
  const { onClose } = useModalContext();
  return (
    <header className="modal-header">
      {children}
      <button onClick={onClose} aria-label="Close modal">&times;</button>
    </header>
  );
}

function ModalBody({ children }: { children: React.ReactNode }) {
  return <div className="modal-body">{children}</div>;
}

function ModalFooter({ children }: { children: React.ReactNode }) {
  return <footer className="modal-footer">{children}</footer>;
}

// Export as a namespace
const Modal = Object.assign(ModalRoot, {
  Header: ModalHeader,
  Body: ModalBody,
  Footer: ModalFooter,
});

export { Modal };

Usage with compound components:

function SettingsPage() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Edit Profile</button>

      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        <Modal.Header>
          <h2>Edit Profile</h2>
        </Modal.Header>
        <Modal.Body>
          <form id="profile-form">
            <label htmlFor="display-name">Display Name</label>
            <input id="display-name" name="displayName" />
            <label htmlFor="bio">Bio</label>
            <textarea id="bio" name="bio" />
          </form>
        </Modal.Body>
        <Modal.Footer>
          <button onClick={() => setIsOpen(false)}>Cancel</button>
          <button type="submit" form="profile-form">Save Changes</button>
        </Modal.Footer>
      </Modal>
    </>
  );
}

Portals for Tooltips

Portals work great for tooltips that need to escape container boundaries:

function Tooltip({ children, text, position = 'top' }: {
  children: React.ReactNode;
  text: string;
  position?: 'top' | 'bottom' | 'left' | 'right';
}) {
  const [isVisible, setIsVisible] = useState(false);
  const [coords, setCoords] = useState({ top: 0, left: 0 });
  const triggerRef = useRef<HTMLSpanElement>(null);

  function showTooltip() {
    if (!triggerRef.current) return;
    const rect = triggerRef.current.getBoundingClientRect();

    const positions = {
      top: { top: rect.top - 8, left: rect.left + rect.width / 2 },
      bottom: { top: rect.bottom + 8, left: rect.left + rect.width / 2 },
      left: { top: rect.top + rect.height / 2, left: rect.left - 8 },
      right: { top: rect.top + rect.height / 2, left: rect.right + 8 },
    };

    setCoords(positions[position]);
    setIsVisible(true);
  }

  return (
    <>
      <span
        ref={triggerRef}
        onMouseEnter={showTooltip}
        onMouseLeave={() => setIsVisible(false)}
        onFocus={showTooltip}
        onBlur={() => setIsVisible(false)}
        tabIndex={0}
      >
        {children}
      </span>
      {isVisible &&
        createPortal(
          <div
            className={`tooltip tooltip-${position}`}
            style={{ position: 'fixed', top: coords.top, left: coords.left }}
            role="tooltip"
          >
            {text}
          </div>,
          document.body
        )}
    </>
  );
}

Portal Event Bubbling

A key feature of portals is that events bubble through the React tree, not the DOM tree:

function Parent() {
  return (
    <div onClick={() => console.log('Parent clicked')}>
      <Portal>
        <button onClick={() => console.log('Button clicked')}>
          Click me
        </button>
      </Portal>
    </div>
  );
}
// Clicking the button logs both "Button clicked" and "Parent clicked"

This is important for context providers. A modal rendered through a portal still has access to context from its React parent, even though it lives at document.body in the DOM.

Common Pitfalls

Forgetting to clean up scroll lock: Always restore document.body.style.overflow in the cleanup function of your effect. If the component unmounts without cleanup, the page stays locked.

Multiple modals: If you open a modal from within a modal, the second modal’s close handler might restore scroll too early. Use a counter or stack to track how many modals are open.

Server-side rendering: document.body does not exist on the server. Guard portal rendering with a mounted check:

function SafePortal({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) return null;
  return createPortal(children, document.body);
}

Wrapping Up

React Portals solve the DOM hierarchy problem for overlays. Combine them with focus trapping, keyboard handling, scroll locking, and proper ARIA attributes to build modals that are both functional and accessible. The compound component pattern gives consumers flexibility to compose modal content however they need. Use portals whenever you need to render something that should visually escape its parent container while staying connected to it in the React tree.