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
onCloseif 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">
×
</button>
</header>
<div className="modal-body">{children}</div>
</div>
</FocusTrap>
</div>
</Portal>
);
}Notice role=














