Portals + overlays cheatsheet.
createPortal
import { createPortal } from "react-dom";
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body,
);
}
Renders children into a different DOM node, while keeping the React tree relationship (events bubble through React, not DOM).
Why portals
- Modals that escape
overflow: hiddenparents. - Tooltips/popovers that must not be clipped.
- Nested z-index hell.
Mount node
// Set up a stable target
function getModalRoot() {
let el = document.getElementById("modal-root");
if (!el) {
el = document.createElement("div");
el.id = "modal-root";
document.body.appendChild(el);
}
return el;
}
createPortal(content, getModalRoot());
Or in index.html:
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
Modal pattern
function Modal({ open, onClose, children }) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div role="dialog" aria-modal="true" className="overlay" onClick={onClose}>
<div className="dialog" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body,
);
}
Focus trap
npm i focus-trap-react
import FocusTrap from "focus-trap-react";
<FocusTrap focusTrapOptions={{ initialFocus: false }}>
<div role="dialog">
<button>Close</button>
<input />
</div>
</FocusTrap>
Or use react-aria / shadcn / Radix dialogs which handle this for you.
Body scroll lock
useEffect(() => {
if (!open) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = original; };
}, [open]);
<dialog> element (preferred today)
const ref = useRef<HTMLDialogElement>(null);
function open() { ref.current?.showModal(); }
function close() { ref.current?.close(); }
<dialog ref={ref}>
<p>Hello</p>
<button onClick={close}>OK</button>
</dialog>
Native focus trap, ESC handling, scroll lock. Style via ::backdrop.
Popover positioning
npm i @floating-ui/react
import { useFloating, autoUpdate, offset, flip, shift } from "@floating-ui/react";
function Popover({ children, content }) {
const [open, setOpen] = useState(false);
const { refs, floatingStyles } = useFloating({
open,
onOpenChange: setOpen,
placement: "bottom",
middleware: [offset(8), flip(), shift()],
whileElementsMounted: autoUpdate,
});
return (
<>
<button ref={refs.setReference} onClick={() => setOpen(!open)}>
{children}
</button>
{open && (
<div ref={refs.setFloating} style={floatingStyles}>
{content}
</div>
)}
</>
);
}
Use a UI library
Don’t roll your own modal/popover for production. Use:
- Radix UI: unstyled, accessible primitives.
- Headless UI: similar.
- shadcn/ui: Radix + Tailwind, copy-into-repo.
- react-aria: most rigorous accessibility.
Accessibility is hard. Outsource it.
Toast pattern
npm i sonner
import { Toaster, toast } from "sonner";
<Toaster />
toast.success("Saved!");
Tooltip
import { Tooltip } from "@radix-ui/react-tooltip";
<Tooltip>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<Tooltip.Content>tip text</Tooltip.Content>
</Tooltip>
When NOT to portal
- Inline content that doesn’t need to escape clipping.
- Components meant to be styled by parent context (some inheritance breaks across portals).
Common mistakes
- Portal without ARIA — screen readers miss it.
- Missing focus trap — Tab escapes modal.
- Body scroll not locked — background scrolls.
- Click on overlay not stopped at dialog → closes when clicking inside.
- Z-index war instead of using portal.
Read this next
If you want my modal + popover patterns, they’re at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .