React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

portalmodaltooltipoverlayreact-patterns

React Portals — Render children into a DOM node outside the parent component's DOM hierarchy

Recipe

import { createPortal } from "react-dom";
 
function Modal({ isOpen, onClose, children }: {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}) {
  if (!isOpen) return null;
 
  return createPortal(
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div className="fixed inset-0 bg-black/50" onClick={onClose} />
      <div className="relative bg-white rounded-lg p-6 max-w-md w-full">
        {children}
      </div>
    </div>,
    document.body
  );
}
 
// Usage
<Modal isOpen={showModal} onClose={() => setShowModal(false)}>
  <h2>Confirm action</h2>
  <p>Are you sure?</p>
</Modal>

When to reach for this: When a component needs to render visually outside its parent's DOM tree (modals, dropdowns, tooltips, toasts) but still participate in the React event and context tree.

Working Example

import { createPortal } from "react-dom";
import { useState, useEffect, useRef, useCallback, type ReactNode } from "react";
 
// --- Accessible modal with focus trap ---
 
function Modal({
  isOpen,
  onClose,
  title,
  children,
}: {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
}) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);
 
  // Trap focus and handle escape
  useEffect(() => {
    if (!isOpen) return;
 
    previousFocusRef.current = document.activeElement as HTMLElement;
    dialogRef.current?.focus();
 
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        onClose();
        return;
      }
 
      if (e.key === "Tab" && dialogRef.current) {
        const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        const first = focusable[0];
        const last = focusable[focusable.length - 1];
 
        if (e.shiftKey && document.activeElement === first) {
          e.preventDefault();
          last?.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
          e.preventDefault();
          first?.focus();
        }
      }
    };
 
    document.addEventListener("keydown", handleKeyDown);
    document.body.style.overflow = "hidden";
 
    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      document.body.style.overflow = "";
      previousFocusRef.current?.focus();
    };
  }, [isOpen, onClose]);
 
  if (!isOpen) return null;
 
  return createPortal(
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      {/* Backdrop */}
      <div
        className="fixed inset-0 bg-black/50 animate-fade-in"
        onClick={onClose}
        aria-hidden="true"
      />
      {/* Dialog */}
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-label={title}
        tabIndex={-1}
        className="relative bg-white rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4 animate-scale-in"
      >
        <div className="flex justify-between items-center mb-4">
          <h2 className="text-xl font-semibold">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close dialog"
            className="p-1 rounded hover:bg-gray-100"
          >
            X
          </button>
        </div>
        {children}
      </div>
    </div>,
    document.body
  );
}
 
// --- Tooltip using portal ---
 
function Tooltip({
  children,
  content,
}: {
  children: ReactNode;
  content: string;
}) {
  const [visible, setVisible] = useState(false);
  const [coords, setCoords] = useState({ top: 0, left: 0 });
  const triggerRef = useRef<HTMLSpanElement>(null);
 
  const show = useCallback(() => {
    if (triggerRef.current) {
      const rect = triggerRef.current.getBoundingClientRect();
      setCoords({
        top: rect.top - 8 + window.scrollY,
        left: rect.left + rect.width / 2 + window.scrollX,
      });
    }
    setVisible(true);
  }, []);
 
  return (
    <>
      <span
        ref={triggerRef}
        onMouseEnter={show}
        onMouseLeave={() => setVisible(false)}
        onFocus={show}
        onBlur={() => setVisible(false)}
      >
        {children}
      </span>
      {visible &&
        createPortal(
          <div
            role="tooltip"
            className="absolute -translate-x-1/2 -translate-y-full px-2 py-1 bg-gray-900 text-white text-sm rounded pointer-events-none"
            style={{ top: coords.top, left: coords.left }}
          >
            {content}
          </div>,
          document.body
        )}
    </>
  );
}
 
// --- Usage ---
function SettingsPage() {
  const [showConfirm, setShowConfirm] = useState(false);
 
  return (
    <div className="p-8">
      <h1>Settings</h1>
      <Tooltip content="This will delete all your data">
        <button
          onClick={() => setShowConfirm(true)}
          className="text-red-600 underline"
        >
          Delete Account
        </button>
      </Tooltip>
 
      <Modal
        isOpen={showConfirm}
        onClose={() => setShowConfirm(false)}
        title="Delete Account"
      >
        <p className="mb-4">This action cannot be undone. Are you sure?</p>
        <div className="flex gap-2 justify-end">
          <button onClick={() => setShowConfirm(false)} className="btn-secondary">
            Cancel
          </button>
          <button className="btn-danger">Delete</button>
        </div>
      </Modal>
    </div>
  );
}

What this demonstrates:

  • Modal portal with focus trap, escape key handling, and scroll lock
  • Tooltip portal positioned relative to its trigger element
  • ARIA attributes for accessibility (role="dialog", aria-modal, aria-label)
  • Previous focus restoration when the modal closes

Deep Dive

How It Works

  • createPortal(children, domNode) renders children into domNode instead of the parent's DOM node.
  • Despite the DOM placement, portals remain part of the React tree. Context, events, and state flow as if the portal is still a child of its React parent.
  • Event bubbling works through the React tree, not the DOM tree. A click inside a portal will bubble to the React parent, not the DOM parent.
  • Portals are commonly used to escape CSS stacking contexts (overflow: hidden, z-index containers) that would clip overlays.

Parameters & Return Values

ParameterTypePurpose
childrenReactNodeThe React elements to render in the portal
domNodeElement or DocumentFragmentThe DOM node to render into
key (optional)stringUnique key for the portal
Return valueReactPortalA special React element representing the portal

Variations

Dynamic portal container — create and clean up a dedicated DOM node:

function usePortalContainer(id: string) {
  const [container, setContainer] = useState<HTMLElement | null>(null);
 
  useEffect(() => {
    let element = document.getElementById(id);
    if (!element) {
      element = document.createElement("div");
      element.id = id;
      document.body.appendChild(element);
    }
    setContainer(element);
 
    return () => {
      if (element && element.childNodes.length === 0) {
        element.remove();
      }
    };
  }, [id]);
 
  return container;
}
 
function ToastContainer({ children }: { children: ReactNode }) {
  const container = usePortalContainer("toast-root");
  if (!container) return null;
  return createPortal(children, container);
}

Server-side compatible portal — guard against missing document:

function ClientPortal({ children }: { children: ReactNode }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
 
  if (!mounted) return null;
  return createPortal(children, document.body);
}

TypeScript Notes

  • createPortal returns ReactPortal, which is a valid ReactNode.
  • The second argument must be an Element or DocumentFragment, not null. Guard with a conditional or state check.
  • When using portals in Next.js, always guard with a client-side mount check since document is unavailable during SSR.

Gotchas

  • CSS inheritance breaks — Styles inherited from parent DOM elements (font, color) don't apply to portaled content since it lives elsewhere in the DOM. Fix: Ensure portaled content defines its own base styles or use a CSS reset wrapper.

  • SSR errorsdocument.body doesn't exist during server-side rendering. Fix: Guard portal rendering with a useEffect-driven mount check or use "use client".

  • Multiple portals and z-index wars — Several portals rendering to document.body can stack unpredictably. Fix: Use a dedicated portal root with a stacking context, or manage z-index through a portal manager.

  • Event bubbling confusion — Events bubble through the React tree, not the DOM tree. A click inside a modal portal bubbles to the React parent, which may trigger unintended handlers. Fix: Use e.stopPropagation() in the portal content if the parent has competing click handlers.

  • Focus management — Opening a portal without moving focus leaves keyboard users stranded. Fix: Move focus into the portal on open and restore it on close, as shown in the working example.

Alternatives

ApproachTrade-off
createPortalFull control; manual focus and accessibility management
HTML <dialog> elementNative modal with built-in focus trap; limited styling
Radix Dialog / Headless UIFull accessibility built-in; extra dependency
CSS position: fixed without portalSimpler; breaks inside overflow: hidden or transform parents
Popover API (popover attribute)Native browser API; limited browser support and React integration

FAQs

What does createPortal do and why is it needed?
  • createPortal(children, domNode) renders React children into a DOM node outside the parent's DOM hierarchy.
  • It is needed to escape CSS stacking contexts (overflow: hidden, z-index containers) that would clip overlays.
  • Common use cases include modals, tooltips, dropdowns, and toast notifications.
Do portals stay part of the React component tree even though they render elsewhere in the DOM?
  • Yes. Despite the DOM placement, portals remain part of the React tree.
  • Context, events, and state flow as if the portal is still a child of its React parent.
  • This is a key difference from manually appending DOM elements.
How does event bubbling work with portals?
  • Events bubble through the React tree, not the DOM tree.
  • A click inside a modal portal bubbles to the React parent, not the DOM parent (document.body).
  • This can trigger unintended handlers on the React parent. Use e.stopPropagation() if needed.
How do you implement focus trapping in a modal portal?
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
 
if (e.shiftKey && document.activeElement === first) {
  e.preventDefault();
  last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
  e.preventDefault();
  first?.focus();
}
  • Query all focusable elements inside the dialog.
  • On Tab, wrap focus from last to first (and vice versa with Shift+Tab).
Gotcha: Why does portaled content lose CSS inherited styles?
  • Portaled content lives in a different DOM location (e.g., document.body), so it does not inherit parent styles like font, color, or line-height.
  • Fix: ensure portaled content defines its own base styles or uses a CSS reset wrapper.
Gotcha: Why does createPortal crash during server-side rendering?
  • document.body does not exist during SSR.
  • Fix: guard portal rendering with a useEffect-driven mount check or use "use client" in Next.js.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(children, document.body);
What is the return type of createPortal in TypeScript?
  • createPortal returns ReactPortal, which is a valid ReactNode.
  • The second argument must be an Element or DocumentFragment, not null.
  • Guard with a conditional or state check to avoid passing null.
How do you type the domNode parameter of createPortal in TypeScript?
  • The parameter type is Element | DocumentFragment.
  • When using a ref or state to hold the container, type it as HTMLElement | null and conditionally render.
  • Example: const [container, setContainer] = useState<HTMLElement | null>(null);
How do you create a dynamic portal container that cleans up after itself?
function usePortalContainer(id: string) {
  const [container, setContainer] = useState<HTMLElement | null>(null);
  useEffect(() => {
    let el = document.getElementById(id);
    if (!el) {
      el = document.createElement("div");
      el.id = id;
      document.body.appendChild(el);
    }
    setContainer(el);
    return () => { if (el && el.childNodes.length === 0) el.remove(); };
  }, [id]);
  return container;
}
  • Create the container on mount and remove it on unmount if empty.
  • This avoids accumulating stale DOM nodes.
Why should you restore focus when a modal portal closes?
  • Without focus restoration, keyboard users lose their place in the page after the modal closes.
  • Save the previously focused element before opening the modal and call .focus() on it in the cleanup function.
  • This is an accessibility requirement for dialogs.
How do multiple portals interact with z-index stacking?
  • Several portals rendering to document.body can stack unpredictably because they share the same stacking context.
  • Fix: use a dedicated portal root with a managed stacking context, or assign z-index values through a portal manager.
  • Alternatively, render portals in order of priority so later portals naturally stack above earlier ones.