#React#TypeScript

React Portal: Modals, Tooltips, and Stacking Contexts

A modal rendered deep in a component tree can be clipped by parent z-index, overflow, or transform rules. Portal renders it in document.body to escape those constraints. Learn Portal fundamentals, a complete modal implementation, accessibility, and why Radix Dialog handles the hard parts.

Key Points

  • Topic Focus: React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

You wrote a modal with z-index: 9999 and confidently opened it. Then it gets clipped by a parent container's overflow: hidden, or ends up beneath a neighboring element with transform: translateZ(0). That 9999 does absolutely nothing. You start increasing z-index, reach 999999, and it still will not cover the other element. Eventually, you can only move the entire component somewhere else.

This is a classic React problem where "the DOM tree does not equal the visual hierarchy." Your modal logically belongs to a deep component in the React component tree, but visually it should float above the entire page. CSS stacking contexts only recognize DOM ancestors, not your component tree. Portal exists to resolve this contradiction: it lets a component's DOM node escape its parent and mount directly under document.body or any other node while preserving the full React context.

But Portal is only one-sixth of the modal problem. A truly usable modal must also handle focus trapping, Escape-to-close, body scroll locking, backdrop-click closing, accessibility semantics, and focus restoration after closing. Those are the parts that make hand-written modals genuinely laborious. This article explains how Portal works, provides a complete usable modal implementation, and finally explains why you should not hand-write one in 90% of cases.

1. Why Portal Is Needed: Your Modal Is Trapped by Its Parent

First, make the problem clear. Suppose you have this structure:

tsx
function Dashboard() {
  return (
    <main style={{ overflow: "hidden", transform: translateY(0) }}>
      <Sidebar />
      <Content />
      <UserMenu /> {/* 里面会弹出一个 Modal */}
    </main>
  );
}

The Modal opened from UserMenu is a descendant of <main> in the DOM. That collides with three CSS rules, each of which can independently ruin your modal:

  1. overflow: hidden / auto / scroll: Once a parent has one of these values, child elements extending beyond the parent's box are clipped. If the modal is larger than its parent or positioned at the edge of the viewport, part of it is cut off and even the backdrop cannot cover the full screen.

  2. transform, filter, perspective, will-change: These properties turn an element into a new stacking-context root. As a result, even if a child has position: fixed, it is no longer positioned relative to the viewport, but relative to that transformed ancestor. The modal's positioning is completely disrupted. Worse, z-index comparison is constrained to that new context. Setting your modal to z-index: 999999 still cannot place it above sibling nodes outside that ancestor.

  3. Stacking contexts are inherited from ancestors: Even if you have no z-index problem yourself, as long as any node in the DOM ancestor chain creates a stacking context, your z-index works only inside that context. An element with z-index: 1 in a more outer context can still cover your modal with z-index: 9999.

The core contradiction in one sentence: the modal belongs to a deep component in the React component tree, which is logically correct, but in the DOM tree it is thrown into that component's ancestor chain, which is visually wrong. CSS neither knows nor cares about your component tree. It only sees DOM ancestors.

The solution is direct: move the modal's DOM node out of the problematic area and mount it under document.body. body is directly under the viewport and has none of those troublesome ancestor properties, so z-index and position: fixed work normally. This ability to render a child node elsewhere is Portal.

2. createPortal: Render DOM Anywhere While Preserving React Context

The API is very simple:

tsx
import { createPortal } from "react-dom";

createPortal(children, container);
// createPortal(children, container, key?)

children is the React subtree to render, and container is the target DOM node. React renders children into container instead of the current component's position in the DOM.

But the key value of Portal is not merely "using another mount point." You can change mount points with container.appendChild too. The key is that Portal preserves React semantics. This is its fundamental difference from manual DOM operations:

  • Events bubble through the React tree, not the DOM tree. An onClick triggered inside a Portal bubbles to the Portal's parent in the React component tree, namely the component that declares <MyPortal />, rather than the DOM parent, document.body. This means onClickCapture, Context, and error boundaries written in the parent component remain fully effective for Portal content, just as if it had never moved.
  • Context passes through Portal. A Portal subtree can consume Context, such as theme, i18n, and router Context, provided at the call site's position in the component tree as usual. If you manually append an independent React root, all of that is lost.
  • Automatic cleanup on unmount. When the component unmounts, React automatically removes the nodes from container; you do not need to call removeChild yourself.

The smallest usage example:

tsx
import { createPortal } from "react-dom";
import { useEffect, useState } from "react";

function Tooltip({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);

  // SSR 下没有 document,挂载后再渲染避免水合错误
  if (!mounted) return null;

  return createPortal(
    <div className="tooltip" style={{ position: "absolute", /* ... */ }}>
      {children}
    </div>,
    document.body,
  );
}

Pay attention to the mounted layer. This is the standard defensive pattern for Portal in Next.js and SSR scenarios. There is no document on the server, and accessing it during hydration can throw an error or create a server-client structure mismatch. Delaying Portal rendering until after useEffect runs on the client is safe and does not affect interaction, since a Tooltip must be on the client to respond to pointer input anyway.

A common misconception is to treat Portal as "global state broadcasting." It is not. Portal only solves the problem of "where a DOM node is mounted." It does not solve "how a component obtains global state." Context handles the latter; Portal simply happens not to break it.

3. A Complete Modal Implementation: Portal Is Only 1/6, and You Must Handle the Other Five Parts Too

This is the core of the article. A production-ready modal must satisfy all of the following at once: 1) portal to body to avoid stacking traps; 2) focus trap so Tab cannot leave the modal; 3) Escape closes it; 4) body scrolling is locked while open; 5) clicking the backdrop closes it but clicking content does not; 6) focus returns to the trigger after closing. If you only implement Portal, the modal is still unusable: keyboard users cannot use it properly, screen readers cannot understand it, and the background can still scroll.

Below is a complete, type-safe implementation that can be used directly. First, the component itself:

tsx
// Modal.tsx
import {
  createPortal,
  useEffect,
  useRef,
  useState,
  type ReactNode,
} from "react";

interface ModalProps {
  open: boolean;
  onClose: () => void;
  title?: string;
  children: ReactNode;
}

const FOCUSABLE =
  'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';

export function Modal({ open, onClose, title, children }: ModalProps) {
  const [mounted, setMounted] = useState(false);
  const panelRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLElement | null>(null); // 记住谁打开了模态框

  useEffect(() => setMounted(true), []);

  useEffect(() => {
    if (!open || !mounted) return;

    // ④ 记住触发元素,关闭后归还焦点(⑥)
    triggerRef.current = document.activeElement as HTMLElement;

    // ⑤ 锁住 body 滚动,并记下原 overflow 以便恢复(④)
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";

    // ② 打开时把焦点放进模态框
    const panel = panelRef.current;
    const firstFocusable = panel?.querySelector<HTMLElement>(FOCUSABLE);
    firstFocusable?.focus();

    // ③ Escape 关闭
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
      if (e.key !== "Tab") return;
      // ① 焦点陷阱:Tab/Shift+Tab 不出模态
      const focusables = Array.from(
        panel?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [],
      );
      if (focusables.length === 0) return;
      const first = focusables[0];
      const last = focusables[focusables.length - 1];
      const active = document.activeElement;
      if (e.shiftKey && active === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && active === last) {
        e.preventDefault();
        first.focus();
      }
    };

    document.addEventListener("keydown", onKeyDown);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
      document.body.style.overflow = prevOverflow; // 恢复滚动
      triggerRef.current?.focus(); // 焦点归还
    };
  }, [open, mounted, onClose]);

  if (!mounted || !open) return null;

  return createPortal(
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby={title ? "modal-title" : undefined}
      onClick={onClose} // ⑤ 点击遮罩关闭
    >
      {/* 遮罩 */}
      <div
        style={{
          position: "fixed",
          inset: 0,
          background: "rgba(0,0,0,0.5)",
        }}
      />
      {/* 内容面板:阻止冒泡,点内容不关闭(⑤) */}
      <div
        ref={panelRef}
        onClick={(e) => e.stopPropagation()}
        style={{
          position: "fixed",
          inset: 0,
          display: "grid",
          placeItems: "center",
        }}
      >
        <div style={{ background: "#fff", padding: 24, borderRadius: 8 }}>
          {title && (
            <h2 id="modal-title" style={{ marginTop: 0 }}>
              {title}
            </h2>
          )}
          {children}
        </div>
      </div>
    </div>,
    document.body,
  );
}

Here is why each part is written this way:

  • 1) Focus trap: After the modal opens, a keyboard user's Tab key can only cycle within the modal and cannot move to the page behind it. The implementation manually uses the FOCUSABLE selector. When Tab reaches the first or last item, it calls preventDefault and moves to the last or first item. This selector covers a[href], enabled button elements, form controls, and positive tabindex elements. Omitting a focus trap is the most common serious accessibility flaw in hand-written modals. Visual users may not notice it, but keyboard users are immediately stranded.

  • 2) Focus on entry: When the modal opens, immediately place focus on the first focusable element in the panel so keyboard users can operate it at once. Otherwise, focus remains on the trigger button behind the modal and pressing Tab sends it outside.

  • 3) Escape closes: Listen for keydown, and call onClose for Escape. Notice that the listener is attached to document, not the panel. Focus may not have reached the panel at the instant it opens, so an onKeyDown attached to the panel can miss the event.

  • 4) Body scroll lock: Set document.body.style.overflow to hidden when opening, and restore the original value when closing, rather than blindly setting it to auto. The original page may have used scroll or a custom value. There is a detail here: if multiple modals are open at once, such as a dialog opening another dialog, simple assignment restores hidden too early when the inner one closes. Production implementations normally use reference counting: open +1, close -1, and restore only when it reaches zero. That is omitted here to keep the principle clear.

  • 5) Backdrop click closes; content click does not: The outer div uses onClick={onClose} to handle backdrop clicks. The inner panel calls stopPropagation, so clicking content does not close it accidentally. This uses the Portal event-bubbling behavior discussed in Section 5. Although the content is portaled out, onClick still bubbles through the React tree.

  • 6) Focus restoration: Before opening, remember the trigger element with document.activeElement, then call focus() on it after closing. Screen reader and keyboard users are especially sensitive to this. If focus is lost and moves to body after the modal closes, they must explore from the beginning again.

Usage:

tsx
function App() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>删除项目</button>
      <Modal open={open} onClose={() => setOpen(false)} title="确认删除">
        <p>此操作不可撤销,确定删除吗?</p>
        <button onClick={() => setOpen(false)}>取消</button>
        <button onClick={() => { /* 删除逻辑 */ setOpen(false); }}>
          确认
        </button>
      </Modal>
    </>
  );
}

This implementation already covers 90% of modal scenarios. The remaining 10%, including multiple-modal stacks, enter and exit animations, position: fixed body scroll bleed-through in iOS Safari, and aria-live announcements for screen readers, are separate pitfalls in their own right. This is why the conclusion of the next section is: do not hand-write one.

4. Accessibility: role / aria-modal / aria-labelledby Are Not Decoration

Modal accessibility has two layers. The first is focus management, which the previous section already covers. The second is semantic annotation, which tells screen readers: "this is a modal dialog, and background content should not be interacted with for now."

Three key attributes:

  • role="dialog": Tells assistive technology that this is a dialog. If it cannot be left until an action is completed, such as a forced confirmation dialog or wizard, role="alertdialog" is more accurate.
  • aria-modal="true": Declares that this is modal, so background content should be treated as non-interactive. Newer screen readers use this to confine focus to the modal, alongside your focus trap as a second layer of protection, and hide the background.
  • aria-labelledby (or aria-describedby): Associates the modal title and description with the modal container. Screen readers announce them when it opens, allowing blind users to effectively "see" the title.

That is what the three lines in the previous section mean:

tsx
<div
  role="dialog"
  aria-modal="true"
  aria-labelledby={title ? "modal-title" : undefined}
>
  ...
  <h2 id="modal-title">{title}</h2>

The value of aria-labelledby is the id of the associated title element. It is linked through the ID rather than duplicating the text. If the title is dynamic or provided through children, conventionally use a stable ID such as modal-title. With multiple instances, add a unique prefix generated by useId() to avoid ID collisions.

Another often-overlooked point is background marking for role="dialog". A strictly accessible implementation also adds aria-hidden="true" to the main background content, ensuring older screen readers that do not support aria-modal also do not read the background. Libraries such as Radix implement this layer. In a hand-written implementation, aria-modal plus a focus trap covers mainstream scenarios, but it is not 100% complete.

In one sentence: focus management lets keyboard users use it; ARIA semantics let screen-reader users understand it. Neither can be omitted. Focus management without ARIA, or ARIA without focus management, is not truly usable.

5. The Counterintuitive Part of Event Bubbling: Events Inside Portal Bubble Through the React Tree, Not the DOM Tree

This is the easiest Portal behavior to get wrong and a common interview question. Consider this example:

tsx
function Page() {
  return (
    <div onClick={() => console.log("Page clicked")}>
      <PortalModal />
    </div>
  );
}

function PortalModal() {
  return createPortal(
    <button onClick={() => console.log("button clicked")}>点我</button>,
    document.body, // DOM 上挂在 body 下,不在 Page 的 div 里
  );
}

When you click the button, the DOM tree is body -> button, which has nothing to do with Page's div. Yet the console prints button clicked followed by Page clicked.

The reason is that React's event system uses synthetic events, and bubbling follows the component tree, or fiber tree, rather than the real DOM tree. Portal moves the content in the DOM, but PortalModal remains a descendant of Page in React's component tree. Therefore, the onClick bubbles through the React tree from button to Page's div, as if the Portal never existed.

This behavior is useful and also a trap:

  • Useful: Your Context, capture-phase listeners, and error boundaries all work normally inside Portal. You do not need any adaptation because "the Portal moved elsewhere."
  • Trap: You may think clicking something inside Portal has "already left the parent," but the parent's onClick still fires. For example, a parent div has logic to "close a dropdown when clicking outside." You portal the dropdown menu out, then clicking inside the menu also triggers "outside close," and the menu instantly disappears. Fix it by calling stopPropagation inside Portal or having the parent use ref.contains(e.target) to inspect the real click target.

One related practical detail: do not implement onClose by relying on event bubbling. For example, do not write "close when clicking anywhere except the trigger button" through a global onClick. This kind of logic frequently breaks under Portal because bubbling paths are counterintuitive. Explicitly using a backdrop element with onClick is much more reliable than relying on global event bubbling.

6. Do Not Hand-write It: Radix UI / Headless UI Have Already Solved Every Pitfall for You

The complete implementation in Section 3 took nearly 100 lines of code and deliberately skipped multiple-modal stacks, enter and exit animations, iOS Safari position: fixed scroll bleed-through, useId to prevent ID collisions, aria-hidden background isolation, and more. Every one of those pitfalls can take half a day to debug. Radix UI's Dialog and Headless UI's Dialog have already implemented all of them for you.

What @radix-ui/react-dialog implements that you probably do not want to handle yourself:

  • Complete focus management: opening focus, Tab trapping, and focus restoration on closing are all handled, with onOpenAutoFocus / onCloseAutoFocus for customization.
  • Scroll locking: it uses reference counting, so a multiple-modal stack does not unlock too early. It also handles iOS Safari position: fixed bleed-through, a classic issue where locking body causes the entire viewport to jump to the top.
  • Accessibility: role="dialog" / aria-modal / aria-labelledby / aria-describedby are automatically associated, with aria-hidden background isolation and screen-reader validation.
  • Animations: CSS enter and exit animations are supported natively through data-state="open|closed", and unmount timing is accurate to the end of the animation.
  • Multiple-modal stacks: focus and layering are handled correctly when multiple Dialogs are nested.

Usage, using Radix:

tsx
import * as Dialog from "@radix-ui/react-dialog";

function DeleteDialog({
  open,
  onOpenChange,
}: {
  open: boolean;
  onOpenChange: (v: boolean) => void;
}) {
  return (
    <Dialog.Root open={open} onOpenChange={onOpenChange}>
      <Dialog.Portal>
        <Dialog.Overlay className="overlay" />
        <Dialog.Content className="content">
          <Dialog.Title>确认删除</Dialog.Title>
          <Dialog.Description>此操作不可撤销。</Dialog.Description>
          <div style={{ display: "flex", gap: 8 }}>
            <Dialog.Close asChild>
              <button>取消</button>
            </Dialog.Close>
            <button onClick={() => onOpenChange(false)}>确认</button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Notice <Dialog.Portal>. Internally, it is createPortal(..., document.body), but paired with the entire set of logic above. Headless UI's Dialog from @headlessui/react does something similar with a slightly different style that is more component-compositional. Either is sufficient.

My recommendation is straightforward:

  • Use Radix / Headless UI in production projects. It is not because you cannot write it. You can. It is because every detail in these libraries has been fed by countless issues, and rewriting them means walking through the same minefield again. Accessibility in particular has an absurd number of details in the WAI-ARIA Authoring Practices, and a custom implementation will almost inevitably fail with some screen reader.
  • Only two situations justify hand-writing one: 1) for learning, to understand what Portal and a modal actually do, which is what the first five sections cover; 2) an extremely lightweight scenario, such as an internal tool overlay that does not need accessibility, where you are willing to take on future maintenance. Even then, compare your finished result with Radix's implementation to see what you missed.

A simple rule of thumb: if this modal will face real users, use a library. The amount of code in a hand-written modal is not the 100-line base implementation. It is the additional hundreds of lines needed to make every edge case correct, and those hundreds of lines have zero business value.

Summary

  1. Portal solves the mismatch between component-tree position and DOM hierarchy. A modal belongs to a deep component in the component tree but is trapped in the DOM by parent overflow, transform, and stacking contexts. Portal moves its DOM node to document.body, escaping these constraints so position: fixed and z-index work again.
  2. createPortal(children, container) does more than change the mount point: it preserves React semantics. Context, error boundaries, and event bubbling all follow the React component tree rather than the DOM tree. This is its essential difference from manual appendChild. In SSR, render Portal only after useEffect to avoid hydration errors.
  3. A usable modal must do six things at once: portal to body; trap focus so Tab cannot leave the modal; focus on entry; close on Escape; lock body scrolling; close on backdrop click while stopping propagation from content; and restore focus to the trigger after closing. Portal alone does not make a modal usable. Keyboard users cannot escape it properly, and the background still scrolls.
  4. Both accessibility layers are required: focus management, which lets keyboard users use it, and ARIA semantics, specifically role="dialog" / aria-modal="true" / aria-labelledby, which let screen-reader users understand it. aria-modal is the declaration and the focus trap is the enforcement; they protect each other.
  5. Portal event bubbling follows the React tree, not the DOM tree, which can create counterintuitive bugs in logic such as "close when clicking outside." The solution is to explicitly handle closing with a backdrop element instead of relying on global event bubbling.

Key takeaway: Portal lets a modal "escape" in the DOM and "return home" in React: its body moves, but its identity remains connected. But Portal is only one-sixth of a modal's work. Radix has already handled the remaining accessibility details, so unless you are learning, do not fight the minefield.

Related

Related Content