Blog

Part 42 · Portals in React: Drawing Outside the Parent

A portal draws a component’s tags somewhere else on the page. Learn why modals need one, how events and context still work, and how to build a modal.

Some things on a page must sit on top of everything else. A menu that opens under a button. A small help note. A modal, which is a box over the whole page that the user must deal with first. Part 17 built one.

There is a problem. These boxes are often written inside a small part of the page, like a card. Then the card can cut them off, or other parts of the page can cover them.

React’s answer is a portal. A portal lets a component draw some of its tags in a different place on the page. The component still owns them. This part shows the problem, the fix, and what stays the same. It explains the click test from Part 6 and the click-outside edge case from Part 17. Then we build a modal step by step, and compare it with the browser’s own <dialog> tag.

Try this first

Read this code. Don’t press Run yet.

import { useState } from 'react'

export default function App() {
  const [open, setOpen] = useState(false)

  return (
    <div style={{ height: 200 }}>
      <div style={{ position: 'relative', overflow: 'hidden', height: 90, padding: 8, border: '2px dashed gray' }}>
        <p style={{ margin: 0 }}>A small card</p>
        <button onClick={() => setOpen(!open)}>Menu</button>
        {open && (
          <ul style={{ position: 'absolute', top: 64, left: 8, zIndex: 999, margin: 0, padding: 8, listStyle: 'none', background: 'gold' }}>
            <li>Profile</li>
            <li>Settings</li>
            <li>Log out</li>
          </ul>
        )}
      </div>
    </div>
  )
}

The card has overflow: 'hidden'. The menu has zIndex: 999, which usually means “draw me on top”.

Make a guess. When you press “Menu”, will you see all three items?

Now press Run, then press “Menu”.

Only “Profile” shows. “Settings” is cut in half, and “Log out” is gone. The card’s bottom edge cuts them off. We checked in Chromium, Firefox and WebKit, the engine inside Safari. In all three, the browser drew the card’s parent at the middle of “Settings” and “Log out”, not the menu.

All three <li> tags are still on the page. The card only hides what goes past its edge. And a high zIndex didn’t help.

Why the menu was cut off

Two CSS rules cause most of these problems. CSS is the language for how a page looks. You don’t need to know much of it here.

overflow: hidden cuts off what sticks out

overflow says what a box does with content that doesn’t fit inside it. With hidden, MDN says the content “is clipped at the element’s padding box”. Clipped means cut off. The edge of the box cuts it off.

Cards often use overflow: hidden, for example to keep round corners clean. So a menu inside a card can easily get cut.

zIndex only counts inside its group

zIndex sets which box is drawn on top when two boxes cover the same spot. A bigger number is drawn on top. But there’s a catch, and this example shows it.

export default function App() {
  return (
    <div>
      <header style={{ position: 'relative', zIndex: 1, background: 'lightblue', padding: 8 }}>
        <p style={{ margin: 0 }}>The header</p>
        <p style={{ position: 'absolute', top: 30, left: 40, zIndex: 999, margin: 0, padding: 8, background: 'gold' }}>
          A help note with zIndex 999
        </p>
      </header>
      <main style={{ position: 'relative', zIndex: 2, background: 'white', border: '2px dashed gray', height: 100, padding: 8 }}>
        <p style={{ margin: 0 }}>The main part of the page</p>
      </main>
    </div>
  )
}

Run it. The yellow note has zIndex: 999. It is inside the header, which has zIndex: 1. The main part comes after it, with zIndex: 2.

The lower part of the note is hidden behind the main part. We asked each of the three browsers what it drew there. All three said: the paragraph of the main part.

Here’s why. A box with position and a zIndex starts its own stacking context. That’s a group of boxes that are drawn together, as one layer. The note’s 999 only matters inside the header’s group. Outside, the whole header counts as 1. And 1 is lower than 2.

An everyday example

Think of a sheet of paper inside a closed folder. You can put that sheet on top of every other sheet in the folder. It still can’t sit on top of the folder next to it, because the whole folder sits under it. And whatever sticks out past the folder’s edge gets cut off.

To show the sheet above everything, you take it out of the folder. You put it on top of the whole pile. That’s what a portal does.

The exact version

The folder picture is close, but CSS has more rules. overflow: hidden only cuts off a child placed inside the box. In our menu, the card has position: 'relative', so top: 64 counts from the card. We took position: 'relative' away, and all three items showed in all three browsers. The menu then counted from the page, not the card. A child with position: 'fixed' is placed from the window instead, so it usually gets out of the card. But MDN lists a few styles, like transform, that make the box the starting point again. We tested this: with transform on the card, a fixed child was cut off again.

So you can sometimes fix these problems with CSS alone. But a component deep in the page can’t know what styles its parents have, now or later. A portal takes the tags out of the parents.

createPortal: put the tags somewhere else

createPortal comes from react-dom, not from react. It takes two things:

import { createPortal } from 'react-dom'

const note = createPortal(<p>A note</p>, document.body)

The first is some JSX, the tags you want to show. The second is a DOM node, a tag that is already on the page. Part 14 explained DOM nodes. React puts your JSX inside that node. document.body is the <body> tag, so the JSX goes at the end of the page.

Here is the menu again, with a portal.

import { useState, type MouseEvent } from 'react'
import { createPortal } from 'react-dom'

export default function App() {
  const [spot, setSpot] = useState<{ top: number; left: number } | null>(null)

  function toggle(e: MouseEvent<HTMLButtonElement>) {
    if (spot) {
      setSpot(null)
      return
    }
    const box = e.currentTarget.getBoundingClientRect()
    setSpot({ top: box.bottom + 4, left: box.left })
  }

  return (
    <div style={{ height: 200 }}>
      <div style={{ position: 'relative', overflow: 'hidden', height: 90, padding: 8, border: '2px dashed gray' }}>
        <p style={{ margin: 0 }}>A small card</p>
        <button onClick={toggle}>Menu</button>
        {spot && createPortal(
          <ul style={{ position: 'fixed', top: spot.top, left: spot.left, zIndex: 999, margin: 0, padding: 8, listStyle: 'none', background: 'gold' }}>
            <li>Profile</li>
            <li>Settings</li>
            <li>Log out</li>
          </ul>,
          document.body
        )}
      </div>
    </div>
  )
}

Run it and press “Menu”. All three items show now. We checked in all three browsers: each item is drawn at its own place, not covered.

Look at how it’s built.

  • The <ul> is still written inside the card, in the JSX. But createPortal(..., document.body) puts its tags at the end of <body>.
  • Out there, top: 64 would count from the top of the page, not from the card. So we measure the button when it is clicked. getBoundingClientRect() gives a tag’s place in the window. We put the menu just under the button.
  • position: 'fixed' places the menu from the window. MDN says a fixed box “remains in the same place” when the page scrolls. So if the page scrolls, this simple menu stays put while the button moves. Real menu libraries measure again when you scroll.

The menu’s tags are no longer inside the app’s own box. Let’s see where they went.

Where the tags end up

Let’s look closer. This example puts a note in a portal, and then asks where the note is.

import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'

export default function App() {
  const noteRef = useRef<HTMLParagraphElement>(null)

  useEffect(() => {
    const note = noteRef.current
    console.log('parent of the note:', note?.parentElement?.tagName)
    console.log('last tag in body:', document.body.lastElementChild === note)
  }, [])

  return (
    <div style={{ border: '2px solid black', padding: 8 }}>
      <p>I am inside the box.</p>
      {createPortal(<p ref={noteRef}>I am a note.</p>, document.body)}
    </div>
  )
}
parent of the note: BODY
last tag in body: true
parent of the note: BODY
last tag in body: true

The JSX looks like the note is inside the box. But its parent tag is <body>, and it’s the last tag in the body.

Each line shows twice because of Strict Mode. In development, React runs an Effect’s setup, then its cleanup, then its setup again, as Part 12 explained. With Strict Mode off, you see each line once.

React’s docs call this “the physical placement of the DOM node”. A portal changes only that. In every other way, the JSX “acts as a child node of the React component that renders it”. The rest of this part is about what that means.

A portal in the playground

The examples on this page each run in their own small page, inside a box under the code. So document.body here means the body of that small page, not this lesson’s page.

Inside that small page, the app comes first, then the Console area. The portal’s tags come after both. In our test, the note from the last example was drawn under the Console area. For a modal, that doesn’t matter. A fixed box is placed from the window, not from the tags around it.

The portal still belongs to its React parent

React keeps two trees in mind. One is the tree of components: who renders whom. The other is the tree of tags on the page. Without a portal, the tags follow the components. A portal makes the two trees different. The tags move. The component stays where it was.

Context still reaches it

Part 23 showed that a component reads context from the nearest provider above it. “Above” means above in the React tree.

import { createContext, useContext } from 'react'
import { createPortal } from 'react-dom'

const ThemeContext = createContext('light')

function Note() {
  const theme = useContext(ThemeContext)
  console.log('Note reads', theme)
  return <p>The theme is {theme}.</p>
}

export default function App() {
  return (
    <ThemeContext value="dark">
      <div>
        <p>The box</p>
        {createPortal(<Note />, document.body)}
      </div>
    </ThemeContext>
  )
}
Note reads dark
Note reads dark

Run it. The note sits in <body>, far outside the provider’s tags. It still reads “dark”. The log appears twice because Strict Mode renders each component twice (Part 2). With Strict Mode off, it showed once.

So a modal in a portal can read the theme, the signed-in user, or any other context value.

Clicks go up the React tree

Part 6 showed that a click bubbles up through the parents. One of its interview answers also checked a portal. A click inside it ran its React parent’s onClick. Now let’s see both trees at once.

To see both parents side by side, this example puts the portal into a box in the same app. It doesn’t use <body>. React’s docs show the same trick. Keep the box’s DOM node in state, then make the portal once it’s there. ref={setPageBox} puts the node into state, a ref callback from Part 14.

import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'

const box = { border: '2px solid gray', padding: 8, margin: '8px 0' }

export default function App() {
  const [pageBox, setPageBox] = useState<HTMLElement | null>(null)

  useEffect(() => {
    if (!pageBox) return
    const log = () => console.log('page parent: native listener')
    pageBox.addEventListener('click', log)
    return () => pageBox.removeEventListener('click', log)
  }, [pageBox])

  return (
    <div>
      <section style={box} onClick={() => console.log('React parent: onClick')}>
        <p>React parent</p>
        {pageBox && createPortal(
          <button onClick={() => console.log('button: onClick')}>Click me</button>,
          pageBox
        )}
      </section>
      <section style={box} ref={setPageBox}>
        <p>Page parent</p>
      </section>
    </div>
  )
}
button: onClick
React parent: onClick
page parent: native listener

Make a guess first. The button’s tags are inside the “Page parent” box. Its component is inside the “React parent” box. Which messages will you see?

Run it and press “Click me”. The button’s onClick runs first. Then the React parent’s onClick runs, even though the button isn’t inside its tags. Then the native listener on the page parent runs. A native listener is one you add yourself with addEventListener, with no React in between.

Here is the same thing as a picture. On the left is the React tree, and on the right are the page’s tags.

1. In the React tree, Modal is a child of Card. 2. In the page's tags, the modal sits at the end of <body>. 3. You click the Close button inside the modal. 4. React passes the click up the React tree, to Card's onClick. 5. The browser's own click goes up the tags: div, then body. The React tree (who renders whom) The page's tags (where they are) … App Card onClick Modal <button> Close <body> <div id="root"> <div> the card <div> the modal <button> Close not inside the card's <div> Card's onClick runs the card's <div> is not on this path

A portal: one tree for React, another for the page. Press play, or step through it.

  1. In the React tree, Modal is a child of Card.
  2. In the page’s tags, the modal’s <div> sits at the end of <body>. It is not inside the card’s <div>.
  3. You click the Close button inside the modal.
  4. React passes the click up the React tree: the button, then Modal, then Card. So Card‘s onClick runs.
  5. The browser’s own click event goes up the tags: the modal’s <div>, then <body>. Native listeners there see it. The card’s <div> is not on that path.

React’s docs say it plainly: “Events from portals propagate according to the React tree rather than the DOM tree”. Propagate means travel, here up through the parents.

What about a React onClick on the page parent? It is the button’s parent in the tags, but not in the React tree. We added one and clicked the button. It did not run.

Why the native listener ran last

The page parent’s native listener ran after both React handlers. Here is why. When the portal first appeared, React added its own listeners to the portal’s DOM node. We counted 142 of them, for 87 kinds of events. Those are the same numbers Part 6 found on the app’s root. React added them before our Effect added ours.

When two listeners sit on the same tag, the first one added runs first. We checked this too. We added a native listener to an empty box first, and then made a portal into it. That time, the native listener ran before the React handlers.

You rarely need to know this. Just remember: the tree of tags and the React tree can give different answers.

State, Effects and cleanup

A component that uses a portal is still a normal component. Its state lives in the component. Its Effects run and clean up as usual.

When the component goes away, its portal’s tags go away too. We tested a button that adds and removes a portal note. Each time it closed, the <p> left <body>. When the whole app was removed, nothing was left behind.

Building a modal

Now let’s use all of this. Here is a modal. It goes into document.body, so no card can cut it off. Take your time with it. The sections after it explain each piece.

import { useEffect, useEffectEvent, useId, useRef, useState, type ReactNode, type RefObject } from 'react'
import { createPortal } from 'react-dom'

function useKeyPress(key: string, onPress: () => void) {
  const onKey = useEffectEvent(onPress)
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === key) onKey()
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [key])
}

type ModalProps = {
  title: string
  onClose: () => void
  returnFocusTo?: RefObject<HTMLElement | null>
  children: ReactNode
}

function Modal({ title, onClose, returnFocusTo, children }: ModalProps) {
  const titleId = useId()
  const closeRef = useRef<HTMLButtonElement>(null)
  const pressedBackdrop = useRef(false)
  useKeyPress('Escape', onClose)

  useEffect(() => {
    const opener = returnFocusTo?.current ?? (document.activeElement as HTMLElement | null)
    closeRef.current?.focus()
    return () => opener?.focus()
  }, [returnFocusTo])

  useEffect(() => {
    const before = document.body.style.overflow
    document.body.style.overflow = 'hidden'
    return () => {
      document.body.style.overflow = before
    }
  }, [])

  return createPortal(
    <div
      style={{ position: 'fixed', inset: 0, background: 'rgba(0, 0, 0, 0.5)', display: 'grid', placeItems: 'center' }}
      onPointerDown={(e) => {
        pressedBackdrop.current = e.target === e.currentTarget
      }}
      onClick={(e) => {
        if (pressedBackdrop.current && e.target === e.currentTarget) onClose()
      }}
    >
      <div role="dialog" aria-modal="true" aria-labelledby={titleId} style={{ background: 'white', color: 'black', padding: 16, borderRadius: 8 }}>
        <h2 id={titleId} style={{ marginTop: 0 }}>{title}</h2>
        {children}
        <button ref={closeRef} onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  )
}

export default function App() {
  const [open, setOpen] = useState(false)
  const openRef = useRef<HTMLButtonElement>(null)

  return (
    <div style={{ minHeight: 280 }}>
      <main>
        <h1>My page</h1>
        <div style={{ overflow: 'hidden', height: 60, border: '2px dashed gray', padding: 8 }}>
          <button ref={openRef} onClick={() => setOpen(true)}>Open settings</button>
          {open && (
            <Modal title="Settings" onClose={() => setOpen(false)} returnFocusTo={openRef}>
              <p>Dark mode is on.</p>
            </Modal>
          )}
        </div>
      </main>
    </div>
  )
}

Run it. Press “Open settings”. A dark layer covers the result box, with a white box in the middle. Press Escape, or click the dark layer, to close it.

Notice where Modal is written. It’s inside a small <div> with overflow: 'hidden' and a height of 60. Without the portal, the card would cut it off. With the portal, it covers the whole result box. We measured the dark layer: it was exactly as big as the small page.

The dark layer and closing

The outer <div> is the dark layer behind the box. It’s often called a backdrop. position: 'fixed' and inset: 0 stretch it over the whole window. display: 'grid' with placeItems: 'center' puts the white box in the middle.

The modal closes in three ways.

  • The Close button calls onClose.
  • Escape uses useKeyPress from Part 17, the version with useEffectEvent. It listens on the whole document. So a key press works wherever the focus is.
  • A click on the backdrop. This one needs care, so it gets its own section.

Closing on a backdrop click

The simple check is e.target === e.currentTarget in the backdrop’s onClick. It means “the click landed on the backdrop itself”. A click on the white box lands inside it, so e.target is something else. Part 6 explained target and currentTarget.

But a click has two parts: the press and the release. Say you press on the text in the white box, drag, and let go on the backdrop. Which tag gets the click? MDN says it goes to the closest parent that holds both tags. Here, that is the backdrop. So the simple check closes the modal, even though the user started inside it. We tried this drag in all three browsers, and the modal closed.

So our modal also looks at the press. onPointerDown saves, in a ref, whether the press started on the backdrop. onClick closes only if the press and the click were both on the backdrop. With this code, the same drag left the modal open in all three browsers. A normal click on the backdrop still closed it. A click on the text inside the white box didn’t. The Close button still worked.

You may see other code call e.stopPropagation() in the white box instead. That has the same drag problem: the click lands on the backdrop, so the white box never sees it. In our test, that version closed on the drag too. It also stops every click inside the box from reaching the React parents. If the page above wanted to hear that click, it can’t. Our checks stop nothing.

Stop the page behind from scrolling

While the modal is open, the page behind it should stay still. The scroll-lock Effect, the second one in Modal, sets document.body.style.overflow to 'hidden'. When the modal closes, the cleanup puts back the value that was there before.

It’s the same pattern as the page title in Part 12. Save the old value, change it, and put it back in the cleanup. In our test, the body’s overflow was "hidden" while the modal was open and "" after it closed.

In the playground, the result box is already as tall as its content. So you won’t see much scrolling to stop. On a long page, you will.

On many computers, the page’s scroll bar takes up some width. When overflow: 'hidden' removes it, the page gets wider, and its content can jump to the side. The CSS rule scrollbar-gutter: stable keeps that space. MDN says it stops the layout from changing when the scroll bar comes and goes. You can put it on the <html> tag, in your app’s CSS.

Move the focus in, and back out

An element has focus when it gets your key presses. When a modal opens, the focus should move into it. When it closes, the focus should go back to the button that opened it. Otherwise a keyboard user is lost.

The focus Effect, the first one in Modal, does both:

  1. returnFocusTo is a ref to the button that opens the modal. App passes openRef. If there’s no ref, we use document.activeElement, the element that has focus right now. We save the result as opener.
  2. closeRef.current?.focus() moves the focus to the Close button, using a ref from Part 14.
  3. The cleanup runs when the modal goes away. It calls opener?.focus(), so the focus goes back.

Strict Mode runs setup, cleanup and setup again. Does that break it? We checked. The first cleanup sends the focus back to the opener. So the second setup saves the same opener again. After Escape, the focus was on “Open settings” again, with Strict Mode on and off.

We tried it in three browsers. We moved the focus to “Open settings” and pressed Enter, the way a keyboard user would. In all three, the focus went into the modal, and came back after Escape.

Why pass a ref at all? Because a mouse click doesn’t always give the button focus. MDN says most browsers do, “but Safari does not, by design”. In our Firefox run, the focus was on the page’s body while the click ran, too. Our headless WebKit did focus the button, so it doesn’t show what Safari on a Mac does.

With only document.activeElement, the opener would be the page’s body in those cases. We tested that in jsdom, where a click gives no focus. After Escape, the focus was on the body. With returnFocusTo, it went back to “Open settings”. In all three browsers, a mouse click, then Escape or Close, sent the focus back to “Open settings”.

Tell screen readers what it is

A screen reader is a program that reads the page out loud, for people who can’t see it. It needs to know that this box is a dialog. Another name for a modal is a dialog. The W3C, the group that writes web standards, has a guide for this. Its modal dialog page asks for three things:

  • role="dialog" on the box. A role tells screen readers what kind of thing a tag is.
  • aria-modal="true", which says the page behind can’t be used now.
  • A name. Usually that’s aria-labelledby, set to the id of a visible title. Then a screen reader reads “Settings” as the dialog’s name. With no visible title, aria-label gives the name as text.

useId makes an id that is unique on the page. So two modals never get the same id. Part 46 covers ARIA attributes like these.

What this modal still doesn’t do

The W3C page also says that Tab moves the focus to the next element inside the dialog. It never leaves the dialog. Ours doesn’t do that yet. We pressed Shift+Tab from the Close button. In all three browsers, the focus went to “Open settings”, behind the dark layer.

That matters. The W3C guide warns against aria-modal="true" on a box that isn’t really modal. Mark it modal only when nobody can use the page behind it, the guide says. So keeping Tab inside, called a focus trap, is part of the job. Part 47 builds one.

There’s one more gap. A click on the text inside the white box moved the focus to the page’s body, in all three browsers. Then the focus is outside the modal. A keyboard user’s next Tab starts from there.

This modal is also made for one modal at a time. We opened a second modal on top of the first. One Escape closed both, because both listen on the document. And the page stayed locked. Here’s why. When both close at once, React ran the outer modal’s cleanup first. It put back "". Then the inner modal’s cleanup put back what it had saved, "hidden". Closed one at a time, from the top, the page could scroll again at the end. So code for stacked modals can count how many are open. It lets the page scroll again only when the count reaches zero.

Other things that use portals

A modal is one use. Here are some others.

  • Tooltips are small notes that show when you point at something. They often sit inside cards and tables, where they can get cut off.
  • Menus that open under a button, like our first example.
  • Toasts are short messages that show up at a corner of the screen and then go away. Any part of an app may show one. A portal puts them all in one place on the page. Part 53 builds a toast system.

They all follow the same idea. The component that knows when to show the box keeps it in its JSX. The portal draws it where nothing can cut it off.

The native <dialog> element

Browsers now have their own tag for this, <dialog>. React can show it like any other tag. What makes it a modal is a browser method, showModal().

import { useEffect, useRef, useState } from 'react'

export default function App() {
  const [open, setOpen] = useState(false)
  const dialogRef = useRef<HTMLDialogElement>(null)

  useEffect(() => {
    const dialog = dialogRef.current
    if (!dialog) return
    if (open && !dialog.open) dialog.showModal()
    if (!open && dialog.open) dialog.close()
  }, [open])

  return (
    <div style={{ minHeight: 240 }}>
      <div style={{ overflow: 'hidden', height: 60, border: '2px dashed gray', padding: 8 }}>
        <button onClick={() => setOpen(true)}>Open settings</button>
        <dialog ref={dialogRef} onClose={() => setOpen(false)} aria-labelledby="settings-title">
          <h2 id="settings-title" style={{ marginTop: 0 }}>Settings</h2>
          <p>Dark mode is on.</p>
          <button onClick={() => setOpen(false)}>Close</button>
        </dialog>
      </div>
      <p>The dialog is {open ? 'open' : 'closed'}.</p>
    </div>
  )
}

Run it and press “Open settings”. The dialog is written inside the same small card with overflow: 'hidden'. There is no portal. And yet it isn’t cut off.

showModal() puts the dialog in the browser’s top layer. MDN describes the top layer as one that “sits on top of all other layers”. No parent can cut it off or cover it. We checked in all three browsers. The card ended 92 pixels from the top. The dialog went down to about 214, and the browser drew the dialog at its middle.

Here is what we saw in all three browsers:

  • The focus moved to the first button inside, “Close”. MDN says showModal() moves the focus to the first tag inside that can take it.
  • Escape closed it. React’s onClose ran, so the text changed to “The dialog is closed.”
  • The page behind couldn’t take focus. We called focus() on “Open settings” while the dialog was open. The focus stayed on “Close”.
  • After closing, the focus went back to “Open settings”. But after a mouse click in Firefox, it went to the body. Nothing had focus when the click ran, so there was nothing to go back to. To fix that, focus your own ref to the button in onClose, as our modal does with returnFocusTo.

MDN says the rest of the page becomes inert: it can’t be clicked or focused. Shift+Tab never reached the page behind. But Tab didn’t stay inside the dialog either. Chromium sent the focus out of the result box, to the lesson page. Firefox and WebKit moved it to the dialog itself. MDN gives one more limit. Inside an <iframe>, a small page shown inside another page, only that small page is blocked. MDN says “the rest of the page remains interactive”. Each of our result boxes is an <iframe>, so that’s what you see here.

So showModal() does some of our modal’s jobs for you: Escape, moving the focus in and back out, and keeping the page behind out of reach. It doesn’t do others. We tried it on a tall page. With the dialog open, the mouse wheel still scrolled the page behind, in all three browsers. So you still need the scroll-lock Effect. A click on the backdrop doesn’t close it either. We clicked outside the dialog in all three browsers, and it stayed open. You add that yourself, with the same press-and-click check.

The Effect connects React’s state to the browser. React has no prop for showModal(). MDN says a dialog opened with the open attribute “is non-modal”. So the Effect calls showModal() when open changes. onClose sets open back to false when the browser closes the dialog itself, as with Escape. The Effect checks dialog.open first, so it never opens an open dialog twice.

So why learn portals at all? Many apps already have a modal built with portals. Tooltips, menus and toasts aren’t modal, so showModal() doesn’t fit them.

Common mistakes

The portal’s target doesn’t exist

The kata’s code sample for this part draws its portal into document.getElementById('modal-root'). The examples you can run there use document.body. That only works if the page has a <div id="modal-root">. If it doesn’t, this happens:

import { createPortal } from 'react-dom'

export default function App() {
  return (
    <div>
      <p>The page</p>
      {createPortal(<p>A note</p>, document.getElementById('modal-root')!)}
    </div>
  )
}

Run it. The whole app is gone. The result box shows the error Target container is not a DOM element. We saw the same message in all three browsers.

document.getElementById('modal-root') gave null, because there is no such tag. The ! after it tells TypeScript “trust me, this is not null“. So TypeScript didn’t warn. React’s docs say the node “must already exist”.

The fix: add the <div id="modal-root"> to index.html, next to the root <div>. Or use document.body, which always exists in the browser. Don’t use ! to hide a null you haven’t checked.

Click-outside checks with contains

Part 17’s useClickOutside asks the browser if a press is inside a box, with box.contains(...). contains looks at the tree of tags. A portal’s tags are not inside the box, even when the JSX is. Part 17 named this edge case. Here it is.

import { useEffect, useEffectEvent, useRef, useState, type RefObject } from 'react'
import { createPortal } from 'react-dom'

function useClickOutside(ref: RefObject<HTMLElement | null>, onOutside: () => void) {
  const onPressOutside = useEffectEvent(onOutside)
  useEffect(() => {
    function handlePointerDown(e: PointerEvent) {
      const box = ref.current
      if (box && !box.contains(e.target as Node)) onPressOutside()
    }
    document.addEventListener('pointerdown', handlePointerDown)
    return () => document.removeEventListener('pointerdown', handlePointerDown)
  }, [ref])
}

export default function App() {
  const [open, setOpen] = useState(false)
  const [picked, setPicked] = useState('nothing')
  const boxRef = useRef<HTMLDivElement>(null)
  useClickOutside(boxRef, () => setOpen(false))

  return (
    <div style={{ height: 200 }}>
      <div ref={boxRef} style={{ display: 'inline-block' }}>
        <button onClick={() => setOpen(!open)}>Menu</button>
        {open && createPortal(
          <div style={{ position: 'fixed', top: 50, left: 12, background: 'gold', padding: 8 }}>
            <button onClick={() => { setPicked('Profile'); setOpen(false) }}>Profile</button>
          </div>,
          document.body
        )}
      </div>
      <p>You picked: {picked}</p>
    </div>
  )
}

Run it. Press “Menu”, then press “Profile”. The text still says “You picked: nothing”, and the menu is gone.

The press started on “Profile”. That’s outside the box of tags, so the hook closed the menu right away, on pointerdown. The menu left the page before the click could reach “Profile”. We saw this in all three browsers.

The fix is to check the menu’s tags too. The hook takes a list of refs, and a press inside any of them counts as inside.

import { useEffect, useEffectEvent, useRef, useState, type RefObject } from 'react'
import { createPortal } from 'react-dom'

function useClickOutside(refs: RefObject<HTMLElement | null>[], onOutside: () => void) {
  const onPress = useEffectEvent((target: Node) => {
    const inside = refs.some((ref) => ref.current?.contains(target))
    if (!inside) onOutside()
  })
  useEffect(() => {
    function handlePointerDown(e: PointerEvent) {
      onPress(e.target as Node)
    }
    document.addEventListener('pointerdown', handlePointerDown)
    return () => document.removeEventListener('pointerdown', handlePointerDown)
  }, [])
}

export default function App() {
  const [open, setOpen] = useState(false)
  const [picked, setPicked] = useState('nothing')
  const boxRef = useRef<HTMLDivElement>(null)
  const menuRef = useRef<HTMLDivElement>(null)
  useClickOutside([boxRef, menuRef], () => setOpen(false))

  return (
    <div style={{ height: 200 }}>
      <div ref={boxRef} style={{ display: 'inline-block' }}>
        <button onClick={() => setOpen(!open)}>Menu</button>
        {open && createPortal(
          <div ref={menuRef} style={{ position: 'fixed', top: 50, left: 12, background: 'gold', padding: 8 }}>
            <button onClick={() => { setPicked('Profile'); setOpen(false) }}>Profile</button>
          </div>,
          document.body
        )}
      </div>
      <p>You picked: {picked}</p>
    </div>
  )
}

Now “Profile” works. In all three browsers, the text changed to “You picked: Profile”, and the menu closed. A press on “You picked” still closed it.

useEffectEvent reads the newest refs and onOutside each time. So the Effect doesn’t need them in its list, even though [boxRef, menuRef] is a new array on every render.

Forgetting to send the focus back

import { useEffect, useRef } from 'react'

function useFocusOnOpen() {
  const closeRef = useRef<HTMLButtonElement>(null)
  useEffect(() => {
    closeRef.current?.focus()
  }, [])
  return closeRef
}

This moves the focus in, but never back. We tested our modal with this Effect. After Escape, the focus was on the page’s body. A keyboard user has to start again from the top of the page. The fix is the cleanup from our modal: save document.activeElement first, and focus it in the cleanup.

A scroll lock that stays on

import { useEffect } from 'react'

function useScrollLock() {
  useEffect(() => {
    document.body.style.overflow = 'hidden'
  }, [])
}

There is no cleanup. We tested our modal with this Effect. After the modal closed, the body’s overflow was still "hidden". So the page stayed locked.

A cleanup that always sets '' has a smaller bug. We set the body’s overflow to "clip" first, as if the page needed it. Then we opened and closed the modal. That cleanup left "", so the page lost its own value. Our modal’s cleanup put back "clip".

Practice

Press Edit on the examples above and try these.

  1. In the “React parent” and “Page parent” example, add onClick={() => console.log('page parent: onClick')} to the second <section>. Click “Click me”. Do you see the new line? Then click the words “Page parent”. What do you see now?
  2. In “Try this first”, wrap the <ul> in createPortal(..., document.body). Change nothing else. Add the import for createPortal. Press “Menu”. Do all three items show? Is the menu in the same place?
  3. In the modal, add console.log('lock') at the top of the scroll-lock Effect. Add console.log('unlock') at the top of its cleanup. Open the modal, then press Escape. What does the Console show?
  4. In the modal, add a “Save” button before “Close”. Make the focus go to “Save” when the modal opens.
Answers
  1. No. A click on “Click me” still logs the same three lines. The page parent is the button’s parent in the tags, but not in the React tree. So its onClick doesn’t run. A click on the words “Page parent” logs page parent: native listener, then page parent: onClick. That click started in the page parent’s own tags. This time the native listener ran first. React’s listener on the page parent only handles clicks from inside the portal. A click on the page parent’s own text is handled by React’s listener on the app’s root, higher up. The click reaches the page parent’s native listener before it gets there.
  2. Yes, all three show. But the menu moved. top: 64 now counts from the top of the small page, not from the card. In our test, “Profile” started 72 pixels from the top of the small page, instead of 86. That is why the portal example measures the button.
  3. lock, unlock, lock when it opens. That is Strict Mode running setup, cleanup and setup. Then unlock once more when it closes. Four lines in all.
  4. Add <button ref={closeRef}>Save</button> before the Close button, and take ref={closeRef} off “Close”. You could call the ref firstRef instead. In our test, the focus went to “Save” when the modal opened, and back to “Open settings” after Escape.

Interview questions

Try to answer each one out loud before you open the answer.

What is a portal in React, and when do you use one?

createPortal(children, domNode) from react-dom draws children inside another DOM node, often document.body. The component still owns them. You use it for boxes that must sit above the page: modals, tooltips, menus and toasts. Inside a parent with overflow: hidden, or under a low z-index group, they get cut off or covered.

A strong answer adds that a portal only moves the tags. React still treats the content as a child of the component that renders it, for state, context and events.

A modal has z-index: 9999 but is still covered. Why?

z-index only counts inside its stacking context. A parent with position and its own z-index makes a new one. Then the whole parent is drawn as one layer, at the parent’s z-index. A later box with a higher z-index covers the whole parent, and everything inside it. A bigger number on the child can’t fix that. A portal moves the modal out of the parent.

A strong answer names other things that start a stacking context, like opacity below 1 or a transform. It also says a transform on a parent with overflow: hidden cuts off even position: fixed children.

A button inside a portal is clicked. Which onClick handlers run?

Its own, then the onClick of each React parent, up the React tree. That’s true even when those parents’ tags are far away on the page. React’s docs say events from portals follow “the React tree rather than the DOM tree”. A React onClick on the DOM parent doesn’t run, unless it is also a React parent.

A strong answer adds that native listeners still follow the tag tree. In our test, a native listener on the portal’s DOM parent also ran. It ran after the React handlers, because React had put its own listeners on that node first. If the bubbling causes trouble, React’s docs give two fixes. Stop the event inside the portal, or move the portal higher up the React tree.

Does context work inside a portal?

Yes. Context follows the React tree, and the portal content is still in it. A provider above the component that makes the portal gives its value to the portal content. That’s true even though the content’s tags sit at the end of <body>.

What does an accessible modal need?

From the W3C modal dialog pattern: role="dialog", aria-modal="true" and a name. The name is usually aria-labelledby pointing at the visible title, or else aria-label. Focus moves into the dialog when it opens. Tab and Shift+Tab stay inside it. Escape closes it. When it closes, focus goes back to the element that opened it.

A strong answer adds the warning: only mark it aria-modal="true" if the page behind really can’t be used. The inert attribute on the app’s own container is one way to make that true. The portal’s tags sit outside it, so they still work. It also keeps the page behind still while the modal is open, and puts back the old style afterwards. Removing the scroll bar can make the page jump to the side, and scrollbar-gutter: stable keeps its space.

Should you use the native <dialog> element or a portal?

For a modal, <dialog> with showModal() does a lot for you. It goes into the top layer, so no parent can cut it off. The rest of the page becomes inert. Escape closes it. The focus moves in, and goes back when it closes. In React you call showModal() from an Effect or an event handler, and listen for onClose.

It doesn’t do everything. The page behind can still scroll, so you still lock it. A click on the backdrop doesn’t close it unless you add that. Inside an <iframe>, only that frame is blocked. A mouse click may give the button no focus. Then the focus has nothing to go back to, so keep your own ref to the opener.

A strong answer says a portal is still the tool for things that aren’t modal: tooltips, menus and toasts. It may add that many existing apps and libraries use portal modals, so you need to read both.

A menu in a portal closes before its item can be clicked. Why?

The click-outside code uses box.contains(e.target). contains looks at the tag tree, and the menu’s tags are in <body>, not inside the box. So a press on a menu item counts as outside. The menu closes on pointerdown, and the click never reaches the item. The fix is to check the menu’s own node too, with a second ref.

A strong answer adds another way. React events follow the React tree, so a React onPointerDown on the wrapper also hears presses inside the portal. In our test, it ran for a press inside the portal, while contains said false.

Sources

  • createPortal, react.dev: the two arguments, “The node must already exist”, “the physical placement of the DOM node”, “acts as a child node of the React component that renders it”, “Events from portals propagate according to the React tree rather than the DOM tree”, a portal into a DOM node kept in state, and the advice to follow the WAI-ARIA modal practices.
  • Common components, react.dev: the onClose and onCancel events of <dialog>.
  • WAI-ARIA Authoring Practices, Dialog (Modal) Pattern: focus moves in, Tab and Shift+Tab stay inside, Escape closes, focus goes back to the opener, role="dialog", aria-modal, aria-labelledby, and when to mark a dialog modal.
  • MDN: overflow (“is clipped at the element’s padding box”), Stacking context, position (fixed boxes, “remains in the same place”, and transform changing the containing box), <dialog> (inert page, Escape, focus), showModal(), Top layer (“sits on top of all other layers”), aria-modal and Node.contains, click (a press on one tag and a release on another fire the click on the closest parent of both), <button> (clicking a button focuses it in most browsers, “but Safari does not, by design”) and scrollbar-gutter (keeps space so the layout doesn’t change). showModal() also says that in an iframe “the rest of the page remains interactive”.
  • react-dom 19.3.0, react-dom.development.js and react-dom-client.development.js: the “Target container is not a DOM element.” check, and React adding its listeners to a portal’s node when the portal first appears, and dispatchEventForPluginEventSystem going ahead only when the clicked tag’s nearest root or portal node is the node that heard the click.
  • The logs, the order of handlers, the listener counts, the focus and overflow results and the error text come from running React 19.3.0 for this post, with Strict Mode on and off. What is cut off, what is covered, where the tags are drawn, the focus after a mouse click, and the <dialog> results come from the playground in headless Chromium 151, Firefox 153 and WebKit 26.5.
  • This part follows the Portal Pattern kata in react-katas. The kata stops the backdrop click with e.stopPropagation() on the inner box. This part checks e.target instead, so React parents still hear the click. The kata also says portals give “Better accessibility for overlays”. A portal alone doesn’t: the focus, the roles and the focus trap are still your job.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.