Home/Learn/React/Portals, Modals and Overlays

Portals, Modals and Overlays

Advanced
Patterns

A portal renders a child into a different DOM node while keeping it in the React tree. It is how modals escape overflow and stacking contexts — and it is the easy half of building one.

Overview

Every overlay hits the same wall: a modal rendered inside a card is clipped by that card's overflow, or trapped beneath a sibling because of a stacking context it cannot escape. A portal solves that by putting the DOM node elsewhere — usually document.body — while events still propagate through the React tree, which is exactly the behaviour you want. What a portal does not give you is any of the accessibility: focus trapping, focus restoration, Escape, scroll locking and correct announcement are all yours to build, which is the real argument for using a library.

What a Portal Does

A different DOM position, the same React tree.

DOM elsewhere, React tree unchanged
import { createPortal } from 'react-dom'

function Modal({ children, onClose }) {
  return createPortal(
    <div className="backdrop" onClick={onClose}>
      <div role="dialog" aria-modal="true" onClick={e => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.body,          // where it renders
  )
}

// DOM position: document.body — free of any ancestor's overflow,
// transform, filter or z-index stacking context.
// React position: still a child of the component that rendered it,
// so context works and events bubble to the React parent.

// That last part surprises people: a click inside the portal fires
// the onClick of the React ancestor, even though the DOM nodes are
// nowhere near each other.

// Portals are also right for tooltips, dropdowns, popovers and
// toasts — anything that must not be clipped by its container.

What a Modal Actually Requires

The checklist. Missing any of it makes the dialog unusable for someone.

Seven requirements, and only one is the portal
// 1. Focus moves into the dialog on open
// 2. Focus is TRAPPED inside while it is open (Tab cannot escape)
// 3. Focus RETURNS to the trigger on close
// 4. Escape closes it
// 5. The background does not scroll
// 6. role="dialog" aria-modal="true" and an accessible name
// 7. Content behind is inert for screen readers

useEffect(() => {
  const previouslyFocused = document.activeElement
  dialogRef.current?.focus()

  const onKey = (e) => e.key === 'Escape' && onClose()
  document.addEventListener('keydown', onKey)

  const { overflow } = document.body.style
  document.body.style.overflow = 'hidden'          // lock scroll

  return () => {
    document.removeEventListener('keydown', onKey)
    document.body.style.overflow = overflow
    previouslyFocused?.focus()                     // restore focus
  }
}, [onClose])

<div role="dialog" aria-modal="true" aria-labelledby="title" tabIndex={-1}>

// Focus trapping is the fiddly one, which is why the honest advice
// is to use Radix, React Aria or Headless UI rather than to rebuild
// it per project.

Layering and Native Alternatives

Stacking, scroll locking on iOS, and the platform dialog.

One portal root, z-index tokens, native dialog
// A single portal root keeps ordering predictable
<div id="portal-root" />        // in index.html, after #root

// Layer with tokens rather than escalating numbers
--z-dropdown: 100; --z-modal: 200; --z-toast: 300;
// The z-index arms race (9999, 99999) is a symptom of stacking
// contexts, not of numbers being too small.

// A modal opening a confirm dialog needs an ordered stack, not two
// components each assuming they are on top.

// The native <dialog> now does much of this in every modern browser
const ref = useRef(null)
ref.current?.showModal()        // focus trap, Escape and ::backdrop free
<dialog ref={ref} onClose={onClose}>...</dialog>
// Still verify focus restoration and scroll locking.

// iOS scroll locking with overflow:hidden alone lets the background
// scroll behind the modal — position: fixed on the body, restoring
// the scroll offset on close, is the usual workaround.

Key Points to Remember

  • 1A portal changes where a child renders in the DOM while keeping it in the React tree, so context and event bubbling still work
  • 2Portals free overlays from ancestor overflow, transform and z-index stacking contexts
  • 3A usable modal needs focus move, focus trap, focus restore, Escape, scroll lock and correct ARIA — the portal is the easy part
  • 4Use a headless library for dialogs rather than rebuilding focus trapping in every project
  • 5Layer overlays with z-index tokens from a single portal root; escalating numbers indicate a stacking-context problem

Interview Questions

Sign in to ask Aria
1

What problem does createPortal solve, and where do events go?

Medium
2

What does a modal need beyond rendering into document.body?

Hard
3

Why does a modal sometimes render behind other content despite a high z-index?

Hard

Ask Aria about Portals, Modals and Overlays

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…