The modal that renders fine in dev and vanishes behind nothing in production:
function SettingsSection() {
return (
<div style={{ transform: "translateZ(0)", overflow: "auto" }}>
{/* ... */}
<Modal open={open}> {/* position: fixed inside here */}
<ConfirmDelete /> {/* is positioned against this div, */}
</Modal> {/* clipped by overflow, not the viewport */}
</div>
);
}position: fixed stops being viewport-fixed the moment any ancestor carries a transform, filter, or contain. Nobody adds those on purpose; a design system animation adds transform and every fixed-position descendant in the app quietly changes meaning. Portals are the escape hatch.
Step 1: Know the trap portals escape
A modal rendered in place inherits every ancestor's overflow, transform, filter, and stacking context:
// in-place: subject to every ancestor
<div style={{ overflow: "hidden" }}>
<Modal /> {/* clipped, z-index trapped in this stacking context */}
</div>
// portaled: DOM lives in body, React tree unchanged
createPortal(<Modal />, document.body);The portal moves the DOM out to document.body while keeping the React tree, context, and event flow intact. The component still receives props and context from its parent; only the paint location changes.
Step 2: Create the container defensively
document.getElementById needs a guard in a server-rendered app:
function getPortalRoot() {
let el = document.getElementById("modal-root");
if (!el) {
el = document.createElement("div");
el.setAttribute("id", "modal-root");
document.body.appendChild(el);
}
return el;
}
const [container] = useState(getPortalRoot);
// useState keeps one container per Modal instance,
// created lazily on the client, never during SSREvery portal call without this either crashes on hydration or leaks a new div per mount. useState with a lazy initializer is the idiomatic once-per-instance home for it.
Step 3: Build the modal shell once, reuse forever
One Modal with the full behavior set:
function Modal({ open, onClose, children }: {
open: boolean;
onClose: () => void;
children: ReactNode;
}) {
const [container] = useState(() => {
if (typeof document === "undefined") return null;
const el = document.createElement("div");
document.body.appendChild(el);
return el;
});
useEffect(() => {
if (!open || !container) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden"; // scroll lock
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose, container]);
if (!open || !container) return null;
return createPortal(
<div className="backdrop" onClick={onClose}>
<div
className="dialog"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
container,
);
}Escape handling, backdrop click, scroll lock, one place. You write it once per app, or take Radix or shadcn which ship all of it. What you should not do is hand-roll a new dialog per feature; that is how apps accumulate five modals with five different Escape behaviors.
Step 4: Manage focus like a requirement, not a nicety
Open with the keyboard and try to reach the browser chrome. Whatever happens is what screen reader users get:
const dialogRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) {
dialogRef.current?.focus(); // focus enters the dialog
return () => triggerRef.current?.focus(); // and returns on close
}
}, [open]);
<div ref={dialogRef} tabIndex={-1} role="dialog" aria-modal="true">
{children}
</div>Move focus into the dialog on open, trap Tab inside while open (a focus-trap library or <dialog> handles the loop), and return focus to the trigger on close. Focus return is the one everyone forgets, and it is the one keyboard users hit within seconds.
Step 5: Clean up the container on unmount
Remove the container when the last modal unmounts:
useEffect(() => {
if (!container) return;
return () => {
container.remove(); // no empty divs, no leaked nodes
};
}, [container]);Single-page apps navigate constantly, and an app-wide modal host that never cleans up accumulates containers and listeners across every route change. The ref-created container makes ownership explicit: this instance made it, this instance removes it.
The full checklist: portal to escape the ancestors, defensive container, one shell with Escape, backdrop, and scroll lock, focus in and out, cleanup on the way down. Every overlay in the app reuses it, and none of them can be taken down by a transform two levels up.
React app with dialogs that clip, mis-layer, or trap keyboards? The audit and the shared shell are about a day. Tell me about the app and I will tell you which modal misbehaves first.