Tech Verse Logo
Enable dark mode
Building Production React Modals with Portals

Building Production React Modals with Portals

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

Why createPortal Isn't Enough

Most tutorials tell you to call createPortal(children, document.body) and call it a day. That gets your DOM node appended to document.body, but it leaves all the hard production bugs unresolved. CSS properties like transform, filter, or perspective on parent elements create new stacking contexts that break z-index rules unless you render outside the main application DOM tree.

In Next.js 16 App Router and React 19, rendering a portal right away causes hydration mismatch errors because document.body doesn't exist during Server-Side Rendering (SSR). You need a mounting check before executing createPortal on the client side.

Here is the baseline pattern for safe mounting in React 19:

'use client';

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

interface PortalProps {
  children: ReactNode;
}

export function Portal({ children }: PortalProps) {
  const [mounted, setMounted] = useState(false);

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

  if (!mounted) return null;

  return createPortal(children, document.body);
}

That handles DOM placement without breaking SSR, but it's only 10% of the work. If your users can't navigate the modal using a keyboard or if the page scrolls underneath, the implementation is broken.

Scroll Locking Without Layout Shift

When a modal opens, you need to prevent the background page from scrolling. Setting document.body.style.overflow = 'hidden' works on desktop Chrome, but it introduces a subtle, annoying bug: when the scrollbar disappears, the entire page content shifts 15px to the right to fill the newly freed space.

On mobile Safari (iOS 17+), setting overflow: hidden on the body doesn't even stop elastic scroll. To solve both issues, you must calculate the scrollbar width dynamically and apply a temporary right padding to the body element equal to that width.

Preventing the Scrollbar Jump

Here is a battle-tested custom hook that locks scrolling and compensates for layout shift on Windows and Linux browsers where scrollbars take up physical layout space:

'use client';

import { useEffect } from 'react';

export function useScrollLock(lock: boolean) {
  useEffect(() => {
    if (!lock) return;

    const originalStyle = window.getComputedStyle(document.body).overflow;
    const originalPaddingRight = document.body.style.paddingRight;

    // Calculate scrollbar width
    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;

    document.body.style.overflow = 'hidden';
    document.body.style.paddingRight = `${scrollbarWidth}px`;

    return () => {
      document.body.style.overflow = originalStyle;
      document.body.style.paddingRight = originalPaddingRight;
    };
  }, [lock]);
}

This prevents that 15px layout twitch when opening dialogs, keeping header navigation aligned while the backdrop is active.

Focus Trapping and Keyboard Accessibility

A modal must act like a focus trap. When a user presses Tab inside an active dialog, focus must cycle exclusively through interactive elements inside that dialog. If focus escapes to the background page, screen reader users get completely lost.

When the modal unmounts, focus must return precisely to the element that triggered it (such as the edit button). If you don't restore focus, the browser resets focus to the top of the body element, forcing keyboard users to tab through the whole page again.

Handling Escape Key and Focus Traps

Don't reach for bloated third-party accessibility libraries for basic modals. We can write a lightweight focus trap using plain JavaScript selectors and standard React hooks.

'use client';

import { useEffect, useRef, ReactNode } from 'react';

const FOCUSABLE_ELEMENTS = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';

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

export function FocusTrap({ children, onClose }: FocusTrapProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    previousFocusRef.current = document.activeElement as HTMLElement;
    const container = containerRef.current;
    if (!container) return;

    const focusableNodes = container.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS);
    if (focusableNodes.length > 0) {
      focusableNodes[0].focus();
    }

    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === 'Escape') {
        event.stopPropagation();
        onClose();
        return;
      }

      if (event.key !== 'Tab') return;

      const nodes = Array.from(
        container?.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS) || []
      );
      if (nodes.length === 0) return;

      const firstNode = nodes[0];
      const lastNode = nodes[nodes.length - 1];

      if (event.shiftKey && document.activeElement === firstNode) {
        event.preventDefault();
        lastNode.focus();
      } else if (!event.shiftKey && document.activeElement === lastNode) {
        event.preventDefault();
        firstNode.focus();
      }
    }

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      previousFocusRef.current?.focus();
    };
  }, [onClose]);

  return <div ref={containerRef}>{children}</div>;
}

Using event.stopPropagation() inside the Escape handler prevents parent handlers from firing prematurely when dealing with nested overlays.

Handling Stacked Modals and Z-Index Queues

What happens when a user opens a confirmation modal on top of an existing form modal? If both modals listen for the Escape key on document, pressing Escape closes both of them simultaneously. That is bad user experience.

Instead of hardcoding global z-index: 9999 everywhere, establish a Modal Context stack that manages active overlays in array order. Only the top-most modal in the stack should react to key presses.

Designing a Modal Manager Context

We store modal instances in a simple central registry so the last opened modal is always the only one listening for user interactions.

  • Stack Array: Push modal IDs when mounted; pop when closed.
  • Top Check: Compare the modal's internal ID with stack[stack.length - 1].
  • Esc Key Priority: Only trigger onClose if the current modal matches the stack top.

If your app interfaces with a PHP Laravel 12 API backend—say, fetching dynamic payload forms into Next.js client containers—keeping modal states centralized in a Context manager keeps render passes predictable and clean.

Putting It All Together

Combining SSR-safe mounting, layout-shift-free scroll locking, focus restoration, and Escape key listeners results in a standard Modal component ready for production production environments.

Here is how the main component interface looks when assembled:

'use client';

import { ReactNode } from 'react';
import { Portal } from './Portal';
import { FocusTrap } from './FocusTrap';
import { useScrollLock } from './useScrollLock';

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

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  useScrollLock(isOpen);

  if (!isOpen) return null;

  return (
    <Portal>
      <div className="modal-backdrop" onClick={onClose}>
        <FocusTrap onClose={onClose}>
          <div
            className="modal-content"
            role="dialog"
            aria-modal="true"
            aria-labelledby="modal-title"
            onClick={(e) => e.stopPropagation()}
          >
            <header className="modal-header">
              <h2 id="modal-title">{title}</h2>
              <button onClick={onClose} aria-label="Close modal">
                &times;
              </button>
            </header>
            <div className="modal-body">{children}</div>
          </div>
        </FocusTrap>
      </div>
    </Portal>
  );
}

Notice role=

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3

    Laravel LLM Integration: Queues and Real-Time Frontend UI

    Laravel LLM Integration: Queues and Real-Time Frontend UI