React Portals: Cleanly Rendering Modals and Tooltips Outside the DOM Tree
AI generated
{ }
React 19 · Portals · UI Patterns
Using React Portals Correctly
Modals and tooltips outside the DOM hierarchy, still anchored in the React tree

A modal nested deep inside a component often gets visually clipped by an ancestor's overflow: hidden. createPortal() renders it into a different DOM node without it losing its logical place in the React tree for events and context.

13 min read createPortal() · React DOM Accessibility

1. Why Portals Exist at All

A modal nested deep inside a product card or a sidebar component automatically inherits the CSS properties of its ancestors. If any parent element sets overflow: hidden, for example to cleanly bound an image gallery, the modal gets clipped at exactly that edge, even though it is meant to cover the entire screen visually. The same happens with z-index: an ancestor with its own stacking context, created for instance by a transform or will-change, can make even a very high z-index value on the modal ineffective, because the comparison only happens within the same stacking context.

React conceptually separates two trees: the React component tree, which governs how props, state and context flow, and the actual DOM tree, where elements physically live. Normally both trees line up, a child in JSX also ends up as a DOM child of the same element. Portals break exactly that coupling: the content stays in its logical place in the React tree, but gets rendered into a freely chosen location in the DOM, typically outside all restrictive containers.

2. createPortal() in Detail

The function createPortal(children, domNode) from the react-dom package takes two arguments: the React content to render, and the target DOM node the content should be mounted into. The return value is a regular React element that can be returned from anywhere in a component's JSX, for example directly from a return statement. Nothing changes for React itself in terms of lifecycle, reconciliation or hook order, only the physical render location in the DOM differs from the logical location in the component tree.

The target node must exist before the portal renders. In many projects an extra div next to the main root element in the base HTML is enough. Alternatively the node can be created dynamically via document.createElement and appended to document.body, which is nicely wrapped in a custom hook that handles creation and cleanup on unmount.


import { createPortal } from 'react-dom';

function Tooltip({ children }) {
  const portalRoot = document.getElementById('portal-root');

  // children renders visually into portalRoot,
  // but stays anchored in the React tree at this spot.
  return createPortal(
    <div className="tooltip">{children}</div>,
    portalRoot
  );
}

3. Event Bubbling Follows the React Tree, Not the DOM

A common misconception is that an element rendered through a portal is completely isolated from events because it lives elsewhere in the DOM. In fact, React propagates its synthetic events along the React component tree, not along the actual DOM structure. A click inside a portaled child therefore still bubbles up to onClick handlers on logical parent components, exactly as if no portal were involved at all.

This matters in practice for a dropdown menu whose option list is rendered through a portal so it isn't clipped by a navigation bar's overflow: hidden. An onClick handler on the outer wrapper meant to close the menu on an outside click still has to account for the fact that clicks inside the portal content still count as clicks inside the component in React's sense.


function Dropdown() {
  const [open, setOpen] = useState(false);

  // A click inside the portal content bubbles up here,
  // even though the menu physically lives in portalRoot.
  return (
    <div onClick={() => setOpen(false)}>
      <button onClick={(e) => { e.stopPropagation(); setOpen(true); }}>
        Open menu
      </button>
      {open && createPortal(
        <ul className="dropdown-list">
          <li>Option A</li>
          <li>Option B</li>
        </ul>,
        document.getElementById('portal-root')
      )}
    </div>
  );
}

4. Context Still Works Through a Portal

A context provider hangs off the React tree, not off the physical DOM location of its consumers. That is why useContext inside a portaled child keeps working reliably, even though that child might live outside the provider in the DOM. React resolves the value based on position in the component tree, and that position is unchanged by the portal.

In practice this matters for theme context, internationalization context or authentication context inside modals. Without this property, every provider would have to be rebuilt around the portal target node, which creates boilerplate and easily leads to inconsistent values if a provider is forgotten. With portals, the provider that already exists in the logical tree is entirely sufficient.


const ThemeContext = createContext('light');

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar() {
  return <Modal />; // Modal sits logically further inside the provider
}

function Modal() {
  const theme = useContext(ThemeContext); // still returns 'dark',
  // even though the portal renders outside the provider's DOM subtree
  return createPortal(
    <div className={`modal modal--${theme}`}>Content</div>,
    document.getElementById('portal-root')
  );
}

A production modal needs more than just createPortal: on open, focus should be moved programmatically onto the modal or an element inside it, so keyboard users land there immediately. A keydown listener on Escape closes the modal, and that listener must be removed on unmount to avoid leaks and duplicate handlers when modals are opened repeatedly.

While the modal is open, the background should no longer be scrollable. It's common to set overflow: hidden directly on document.body and remove it again on close. Skip this step and the background keeps scrolling underneath the modal, which is especially jarring on touch devices.


function Modal({ onClose, children }) {
  const ref = useRef(null);

  useEffect(() => {
    ref.current?.focus();
    document.body.style.overflow = 'hidden';

    const handleKeyDown = (e) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.body.style.overflow = '';
      document.removeEventListener('keydown', handleKeyDown);
    };
  }, [onClose]);

  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={ref}
        tabIndex={-1}
        role="dialog"
        aria-modal="true"
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>,
    document.getElementById('portal-root')
  );
}

6. Positioning Tooltips and Dropdowns

For tooltips and dropdowns, dynamic positioning is added on top of the plain DOM relocation. The trigger element's position is typically read via getBoundingClientRect(), and the portaled content's top and left coordinates are derived from it. That calculation should happen synchronously before the visible frame, otherwise the tooltip briefly appears in the wrong place and visibly jumps to its correct position.

Edge cases such as a viewport collision need extra handling: if there isn't enough room below the trigger element, the tooltip should flip and appear above it instead. Simple cases can be solved with a few lines of custom logic, but for more demanding requirements, automatic alignment on multiple axes, collision detection against scroll containers, a specialized library like Floating UI pays off.


function Tooltip({ triggerRef, children }) {
  const [pos, setPos] = useState(null);

  useLayoutEffect(() => {
    const rect = triggerRef.current.getBoundingClientRect();
    setPos({ top: rect.bottom + 8, left: rect.left });
  }, [triggerRef]);

  if (!pos) return null;

  return createPortal(
    <div
      className="tooltip"
      style={{ position: 'fixed', top: pos.top, left: pos.left }}
    >
      {children}
    </div>,
    document.getElementById('portal-root')
  );
}

7. Managing Multiple Portals and SSR

Once several portal types exist at the same time, say a modal, several toast notifications and a tooltip, a dedicated root node per type is worth setting up. That cleanly separates the respective stacking contexts and prevents, for example, a toast from accidentally disappearing behind a modal overlay just because both share the same container.

No document object exists on the server, so createPortal must not run there. The common solution is an isMounted flag that is only set to true inside a useEffect after the first client render. Only then does the portal actually render, while the server simply ships an empty shell.


function usePortalRoot(id) {
  const [node, setNode] = useState(null);

  useEffect(() => {
    let el = document.getElementById(id);
    let created = false;
    if (!el) {
      el = document.createElement('div');
      el.id = id;
      document.body.appendChild(el);
      created = true;
    }
    setNode(el);
    return () => {
      if (created) el.remove();
    };
  }, [id]);

  return node;
}

8. Accessibility for Portaled Components

A modal needs semantically correct ARIA attributes: role="dialog" and aria-modal="true" signal to screen readers that the rest of the page content is not relevant during the interaction, and aria-labelledby points at the modal heading's ID so the title gets announced on focus. A focus trap keeps tab navigation inside the modal, so keyboard users don't accidentally land on background elements.

When the modal closes, focus must be explicitly returned to the element that originally opened it. Skip this step and focus often lands right on document.body, which feels like a sudden loss of context for keyboard and screen reader users and makes orientation on the page harder.


function useFocusTrap(ref, active) {
  useEffect(() => {
    if (!active) return;
    const node = ref.current;
    const focusable = node.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    const handleTab = (e) => {
      if (e.key !== 'Tab') return;
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    };
    node.addEventListener('keydown', handleTab);
    return () => node.removeEventListener('keydown', handleTab);
  }, [ref, active]);
}

9. Common Mistakes When Using Portals

The most common mistake is recreating the portal target node with document.createElement on every render, instead of creating it once and keeping it stable via a ref or a hook. This causes unnecessary DOM mutations, visible flicker, and in the worst case memory leaks when old nodes never get removed.

A second common problem is z-index collisions between multiple simultaneously open portals, when it isn't clearly defined which layer sits above which. On top of that, event listeners for resize or scroll events used for position calculations are often not cleaned up on unmount and quietly keep running afterwards.

Approach Solves Overflow Issue Event Bubbling in React Tree Recommendation
z-index hack without a portal No, stays within the ancestor's stacking context Yes, unchanged Only for very simple, controlled layouts
position: fixed without a portal Partially, fails with ancestors that have their own stacking context Yes, unchanged Not recommended for nested layouts
createPortal to document.body Yes, fully Yes, follows the React tree Good default for a single modal
createPortal to a dedicated root node Yes, fully, plus clean stacking context separation Yes, follows the React tree Recommended with several concurrent portal types

Mironsoft

React architecture, performance, and Magento frontend integration

React frontends that stay fast instead of slowing down with every feature?

We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.

Performance Audit

Systematically measuring and fixing re-renders, bundle size, and load times.

State Architecture

Cleanly separating context, client state, and server state instead of mixing everything.

Magento Integration

Building robust, type-safe GraphQL or REST integration with Magento.

10. Summary

React Portals for Modals and Tooltips: The Essentials at a Glance

Portal API

createPortal(children, domNode) renders React children into any DOM node, independent of the surrounding layout.

Event Bubbling

Events follow the React tree, not the DOM tree, clicks inside a portal still reach logical parent handlers.

Context

Context providers keep working because they hang off the React tree, not the physical DOM location.

Accessibility

Focus trap, aria-modal and Escape handling belong in every production portal modal.

11. FAQ: React Portals for Modals and Tooltips: The Essentials at a Glance

1What is the difference between createPortal and normal JSX rendering?
Normal JSX renders at the spot in the DOM where the component sits in the tree. createPortal renders the same React content into any other DOM node, while the component stays at its original position in the React tree.
2Why doesn't position fixed simply escape overflow hidden without a portal?
position fixed positions relative to the viewport, but can still be clipped by an ancestor's overflow hidden, especially if that ancestor has its own transform or filter that creates its own stacking context. A portal avoids the problem entirely because the element is no longer part of that ancestor's DOM subtree.
3Do React events really work as expected through a portal?
Yes. React propagates synthetic events along the React component tree, not along the actual DOM structure. A click inside a portaled child still bubbles up to onClick handlers on logical parent components.
4Does every portal need its own DOM node?
Not necessarily, multiple portals can share the same target node such as document.body. For clean separation of stacking contexts, say between a modal, a toast and a tooltip, a dedicated root node each is still recommended.
5How does createPortal behave during server side rendering?
No document object exists on the server, so createPortal must not run there. The common approach is an isMounted flag that gets set inside useEffect after the first client render, only then does the portal actually render.
6How do I prevent the portal root from being recreated on every render?
The DOM node should be created once and kept stable via a ref or a custom hook, instead of recreating it with document.createElement on every render. Otherwise you get unnecessary DOM mutations and potential memory leaks.
7How do I ensure proper keyboard accessibility in a portal modal?
On open, move focus programmatically onto the modal, keep tab navigation inside the modal with a focus trap, close the modal on Escape, and return focus to the triggering element when it closes.
8Can a tooltip be positioned without a portal?
Technically yes, as long as no ancestor sets overflow hidden or a restrictive stacking context. In practice that's rarely guaranteed, which is why portals are the more robust default solution for tooltips in complex layouts.
9What happens to CSS based on parent selectors when I move a child into a portal?
Those CSS selectors stop matching, because the child is no longer a DOM descendant of that parent element. Styles need to be set directly on the portaled element or via global classes and CSS variables instead of relying on inheritance from ancestors.
10Is a dedicated portal library worth it, or does the native API suffice?
For simple cases, createPortal combined with a custom hook for root creation and cleanup is entirely sufficient. For complex positioning logic with collision detection and automatic alignment, a specialized library like Floating UI pays off.