Teleportation in React: Positioning, Stacking Context, and Portals
Tags: webdev, javascript, tutorial, react
Published: 2023-06-27 11:16:00


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y34k34y234oq9j58l32j.jpg)


If you had the chance to gain a superpower, what would it be? How about teleportation? It's one of the most popular ones (at least according to ChatGPT 😅). And no surprise here: who wouldn't want to be able to avoid all the traffic or people breathing down your neck on a bus and just instantly move from point A to point B? And we can actually do that! At least with the code in React 😅 The real thing is not yet available, so let's just teleport components in the DOM for now.

You might have heard that we need Portals in React to escape the "clipping" of content when rendering elements inside elements with `overflow: hidden`. Every second article on the internet about Portals has this example. This is actually not true: we can escape it with just pure CSS. We need Portals for other reasons. This "overflow problem" also might give a false sense of security: if we just don't have any `overflow: hidden` in the app, we can just easily position everything everywhere safely. Also not true.

Let's deep dive into all of this now: how the positioning of elements works, what Stacking Context is, how to escape content clipping with CSS, why we can't do everything with CSS and need Portals, and how Portals actually work.

## CSS: absolute positioning


Let's start with the simplest app and some basics that most people probably already know.

We have a page with some functionality and a button somewhere in the middle. And when the button is clicked, I want to show some additional information:

```tsx
const App = () => {
  const [isVisible, setIsVisible] = useState(false);

  return (
    <>
      <SomeComponent />
      <button onClick={() => setIsVisible(true)}>show more</button>
      {isVisible && <ModalDialog />}
      <AnotherComponent />
    </>
  );
}
```

With this implementation, the additional content, when it appears, will "push" the content from `AnotherComponent` down. This is the normal flow of any HTML document and the default behavior of "block" HTML elements: `div`, `p`, all `h` tags, etc.

But we want to implement that additional content as a modal dialog, and modal dialogs typically appear _on top_ of the page content. What I want is for the `ModalDialog` component to be able to _escape_ the normal document flow. The most common way to achieve that is through the CSS property `position` ([https://developer.mozilla.org/en-US/docs/Web/CSS/position](https://developer.mozilla.org/en-US/docs/Web/CSS/position)).

The `position` property supports two values that allow us to break away from the document flow: `absolute` and `fixed`. Let's start with `absolute` and try to implement the dialog using it. All we need is to apply the `position: absolute` CSS to the div in the `ModalDialog` component:

```tsx
// somewhere where you declare your css
.modal {
  position: absolute;
}

// our React component
const ModalDialog = () => {
  return (
    <div className="modal">
      some additional info
    </div>
  )
}
```

And voila! The content is no longer part of the document layout and appears at the top. Now I just need to position it correctly by setting some meaningful values in the `top` and `left` CSS properties. Assuming I want that dialog in the middle of the screen, the CSS for it would look something like this:

```css
.modal {
  position: absolute;
  width: 300px;
  top: 100px;
  left: 50%;
  margin-left: -150px;
}
```

This dialog will appear in the middle of the screen, with a `100px` gap at the top.

{% embed https://codesandbox.io/embed/simple-position-absolute-88n7w3?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}

So, technically, this works. But if you look at the existing dialogs in your app or any of the UI libraries, it's highly unlikely that they use `position: absolute` there. Or even tooltips, dropdown menus, or any UI element that pops up, really.

There are reasons for it.

## Absolute is not that absolute

First of all, the absolute position is not exactly… absolute. It's actually relative: relative to the closest element with the `position` set to any value. In our case, it just works by accident: because I don't have any positioned elements between my modal dialog and the root of the app.

If the dialog happens to be rendered inside a div with `position: relative` (or `sticky` or `absolute`) and this div is not in the middle of the page, then it all falls apart. The modal will be positioned in the middle of that div, not in the middle of the screen.

{% embed https://codesandbox.io/embed/simple-position-absolute-mispositioned-4hz9nv?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}


Okay, so for elements that are supposed to be positioned relative to the screen, the `absolute` position is not the best choice. Although still possible to calculate, of course, just not with pure CSS.

But what about something like a tooltip or a dropdown menu? Those we would expect to be positioned relative to the element they originate from, isn't it? So the fact that absolute is relative is perfect for that: we can just use `offsetLeft` and `offsetTop` on the trigger to get the left/top distance between the trigger and the parent, and our dialog/tooltip/menu will position itself relative to the trigger all the time perfectly.

And technically, yes, it will work.

{% embed https://codesandbox.io/embed/simple-position-absolute-relative-7vp5fd?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}


Until Stacking Context rules kicks in.

## Understanding Stacking Context


[Stacking Context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context) is a nightmare for anyone who has ever tried to use `z-index` on positioned elements. Stacking Context is a three-dimensional way of looking at our HTML elements. It's like a Z axis, in addition to our normal X and Y dimensions (window width and height), that defines what sits on top of what when an element is rendered on the screen. If an element has a shadow, for example, that overlaps with surrounding elements, should the shadow be rendered on top of them or underneath them? This is determined by Stacking Context.

And the default rules of Stacking Context are quite complicated by themselves. Normally, elements are stacked in the order of their appearance in the DOM. In code like this:

```tsx
<div>grey</div>
<div>red</div>
<div>green</div>
```

The green div is after the red, so it will be "in front" from the Stacking Context rules point of view, and the red will be in front of the grey. If I add a small negative margin to them, we'll see this picture:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a7mdhajkw27u7h1cr3nb.png)


Elements with the `position` set to `absolute` or `relative`, however, will always be pushed forward. If I just add `position: relative` to the red div, the green suddenly appears under it.

```tsx
<div>grey</div>
<div style={{ position: "relative" }}>red</div>
<div>green</div>
```

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mrymzfo8rqg6foe6cxy9.png)


For our absolutely positioned dialog, that would mean that if it's inside that red div, with the position set, it will be okay and on top of everything. But if it's inside grey, then the _red div will be on top of the dialog._

To fix this situation, we have the `z-index` CSS property. This property allows us to manipulate that Z-axis within the same Stacking Context. By default, it's zero. So if I set the `z-index` of the dialog to a negative value, it will appear behind all the divs. If set to positive, then it will appear on top of all the divs.

{% embed https://codesandbox.io/embed/simple-divs-positioning-q6jdk3?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}


_Within the same Stacking Context_ is the key here. If something creates a new Stacking Context, that `z-index` will be relative to the new context. It's a completely isolated bubble. The new Stacking context will be controlled as its own isolated black box by the rules of the parent Context, and what happens inside stays inside.

The combination of `position` and `z-index` on the same element will create its own Stacking Context. From our colorful divs point of view, that would mean that if I add `position: relative; z-index: 1` to the grey div and `position: relative; z-index: 2` to the red, both of them will be parents of their own Stacking Contexts. The grey div and everything inside it will be "underneath" the red one, including our modal dialog. Even if I change the `z-index` on the dialog to the magic `9999` number, it won't matter: the dialog will still appear under the red div.

Play around with that `z-index` on the grey div in the code example below; it's truly fascinating. If I remove it, the new Stacking Context disappears, and the dialog is now part of the global context and its rules and starts appearing on top of the red div. As soon as I add a `z-index` to the grey div that is less than the red div, it moves underneath.

And it's not only the combination of `position` and `z-index` that triggers it, by the way. The `transform` property will do it. So any of your leftover CSS animations have the potential to mess the positioned elements up. Or `z-index` on Flex or Grid children. See the full list [here](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context).

{% embed https://codesandbox.io/embed/divs-stacking-context-t5yxzc?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}

And, of course, finally, the elements with `overflow`. By the way, just setting `overflow` on an element won't clip the absolutely positioned div inside; it needs to be in combination with `position: relative`. But yeah, if an absolutely positioned dialog is rendered inside the div with `overflow` and `position`, then it will be clipped.

{% embed https://codesandbox.io/embed/divs-with-overflow-872y7d?file=/src/App.tsx:1327-1333?codemirror=1&hidedevtools=0&view=preview %}


Can we do something about all of this? Yep, of course. Partially. We can fix the overflow problem in no time at least.

## Position: fixed. Escape the overflow

There is another `position` value that we can use to escape the normal document flow: `fixed` value. It's similar to `absolute`, only it positions the elements not relative to their positioned parents but relative to the viewport. For something like the modal dialog that should be positioned in the middle of the screen, regardless of the parents, this value is much more beneficial.

Also, since it's positioned relative to the screen, this `position` actually allows us to escape the `overflow` trap. So, _in theory_, we could have used it for our dialogs and tooltips.

{% embed https://codesandbox.io/embed/escape-overflow-trap-8w8yr2?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}

However, even `position: fixed` cannot escape the rules of Stacking Context. Nothing can. It's like a black hole: as soon as it forms, everything within its gravitational reach is gone. No one gets out.

In the example above, if I add the `z-index: 1` to the div and add the red div back with `z-index: 2` - it's game over for modals. They will appear underneath.

{% embed https://codesandbox.io/embed/divs-with-fixed-position-and-stacking-context-qr5k7w?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}


Another issue with `position: fixed` is that it's not _always_ positioned relative to the viewport. It's actually positioned relative to what is known as the Containing Block. It just happens to be the viewport most of the time. Unless some of the parents have certain properties set, then it will be positioned relative to that parent. And we'll have the same situation we had at the very beginning with `position: absolute`.

Properties that trigger forming of the new Containing Block for `position: fixed` are relatively rare, but they include `transform`, and that one is widely used for animation. The entire list is available [here](https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block).

## Stacking Context in real apps


Okay, all of this is really fun but a bit theoretical. Would a situation like the Stacking Context trap even happen in a real app? Of course! And quite easily, actually.

The prime candidates are all sorts of animations or "sticky" blocks like headers or columns. Those are the most likely places where we'd be forced to set either `position` with `z-index`, or `translate`. And those will form a new Stacking Context.

Just open a few of your favorite popular websites that have "sticky" elements or animations, open Chrome Dev Tools, find some block deep in the DOM tree, set its position to `fixed` with a high `z-index`, and move it around a bit. Just for the fun of it, I checked Facebook, Airbnb, Gmail, OpenAI, and LinkedIn. On three of those, the main area is a trap: any block with `position: fixed` and `z-index: 9999` within it will appear _underneath_ the sticky header.

There is only one way to escape from that trap: to make sure that the modal is not rendered inside the DOM elements that form Stacking Context. In the world without React, we'd just append that modal to the body or some div at the root of the app with something like:

```tsx
const modalDialog = ... // get the dialog where the button is clicked
document.getElementByClassName('body')[0].appendChild(modalDialog);
```

In React, we can escape that Stacking Context trap with the tool called Portal. Finally, time to do React!

## How can React Portal solve this


Let's recreate the trap in something more interesting than a bunch of colorful divs just to make our code more realistic and to see how easily it can happen. And then fix it for good.

Let's do a very simple app: header with `position: sticky`, the "collapsible" navigation on the left, and the modal dialog inside our main area.

```tsx
const App = () => {
  const [isVisible, setIsVisible] = useState(false);

  return (
    <>
      <div className="header"></div>
      <div className="layout">
        <div className="sidebar">
        // some links here
        </div>
        <div className="main">
          <button onClick={() => setIsVisible(true)}>show more</button>
          {isVisible && <ModalDialog />}
        </div>
      </div>
    </>
  );
}
```

Our header is going to be sticky, so I'll set the sticky position for it:

```css
.header {
  position: sticky;
}
```

And I want our navigation to move into the "collapsed" state smoothly, without any jumping or disappearing blocks. So I'll set the `transition` property on it and the main area:

```css
.main {
  transition: all .3s ease-in;
}

.sidebar {
  transition: all .3s ease-in;
}
```

And translate them to the left when navigation is collapsed and back when it's expanded:

```tsx
const App = () => {
  // hold navigation state here
  const [isNavExpanded, setIsNavExpanded] = useState(true);

  return (
    <>
      <div className="header"></div>
      <div className="layout">
        <div
          className="sidebar"
          // translate the nav to the left if collapsed, and back
          style={{
            transform: isNavExpanded
              ? "translate(0, 0)"
              : "translate(-300px, 0)"
          }}
          >
          ...
        </div>
        <div
          className="main"
          // translate the main to the left if nav is collapsed, and back
          style={{
            transform: isNavExpanded
              ? "translate(0, 0)"
              : "translate(-300px, 0)"
          }}
          >
          // main here
        </div>
      </div>
    </>
  );
}
```

That already works beautifully, except for one thing: when I scroll, the header disappears under the sidebar and the main area. That's no problem, I already know how to deal with it: just need to set `z-index: 2` for the header. Done, and now the header is always on top, and expand/collapse works like a charm!

{% embed https://codesandbox.io/embed/sticky-header-trap-zrts6x?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}


Except for one thing: the modal dialog in the main area is now completely busted. It used to be positioned in the middle of the screen, but not anymore. And when I scroll with it open, it appears under the header. Everything in the code is reasonable, there are no random `position: relative`, and still, that happened. The Stacking Context trap 😡.

In order to fix it, we need to render the modal dialog outside of our main area. In our simple app, we could just move it to the bottom, of course: the button, state, and dialog are within the same component. In the real world, it's not going to be that simple. More likely than not, the button will be buried deep inside the render tree, and propagating state up will be a massive pain and performance killer. Context could help, but it has its own caveats.

Instead, we can use the `createPortal` function that React gives us. Well, technically, the `react-dom` library, but it only matters for the import path in our case. It accepts two arguments:

* _**What**_ we want to teleport in the form of a React Element (our `<ModalDialog />`)
* _**Where**_ we want to teleport it to in the form of a _DOM element._ Not an `id`, but the element itself! We would have to refresh our rusty JavaScript skills for those and write something like `document.getElementById("root")`.

```tsx
import { createPortal } from 'react-dom';

const App = () => {
  return (
    <>
      ... // the rest of the code with the button

      {isVisible && createPortal(<ModalDialog />, document.getElementById("root"))}
    </>
  )
}
```

That's it, the trap is no more! We still "render" the dialog together with the button from our developer experience perspective. But it ends up inside the element with `id="root"`. If you open Chrome Developer Tools, you'll see it right at the bottom of it.

{% embed https://codesandbox.io/embed/sticky-header-trap-with-portal-pq552m?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}


And the dialog is now centered, as it's supposed to be, and appears on top of the header, as it should.

But what are the consequences of doing that? What about re-renders, React lifecycle, events, access to Context, etc.? Easy. The rules of teleportation in React are:

* What happens in React stays in React.
* Where React has no power, the behavior is controlled by the DOM rules.

What does it mean exactly?

## React lifecycle, re-renders, Context, and Portals


From a React perspective, this modal dialog is part of the render tree of the component that created that `<ModalDialog />` element. In our case, the `App` component. If I trigger the **re-render** of the `App`, all components rendered inside of it will re-render, including our dialog, if it's open.

If our `App` has access to **Context**, the dialog will have access to exactly the same Context.

If the part of the app where the dialog is created **unmounts**, the dialog will also disappear.

If I want to intercept a **click event** that happens in the modal, the `onClick` handler on the "main" div will be able to do that. "Click" here is part of synthetic events, so they "bubble" through React tree, not the regular DOM tree. Same story with any synthetic events that React manages: [https://react.dev/learn/responding-to-events#event-propagation](https://react.dev/learn/responding-to-events#event-propagation).

See the app below, which implements all of this:

{% embed https://codesandbox.io/embed/sticky-header-trap-with-portal-and-react-stuff-kh9x5y?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}

## CSS, native JavaScript, form submit, and Portals


From the DOM perspective, this dialog is no longer part of the "main" app. So everything that is DOM-related will change.

If you rely on CSS inheritance and cascading to style the dialog in the "main" part, it won't work anymore.

```tsx
// won't work with portalled modal
.main .dialog {
  background: red;
}
```

If you rely on "native" events propagation, it also won't work. If, instead of the `onClick` callback on the "main" div, you try to catch events that originated in the modal via `element.addEventListener`, it won't work.

```tsx
const App = () => {
   const ref = useRef(null);

   useEffect(() => {
    const el = ref.current;

    el.addEventListener("click", () => {
      // trying to catch events, originated in the portalled modal
      // not going to work!!
    });
  }, []);

  // the rest of the app
  return <div ref={ref} ... />
}
```

If you try to grab the parent of the modal via `parentElement`, it will return the `root` div, not the main app. And the same story with any native JavaScript functions that operate on the DOM elements.

And finally, `onSubmit` on `<form>` elements. This is the least obvious thing about this. It _feels_ the same as `onClick`, but in reality, the _submit event is not managed by React_. It's a native API and DOM elements thing. If I wrap the main part of the app in `<form>`, then clicking on the buttons inside the dialog won't trigger the "submit" event! From the DOM perspective, those buttons are outside of the form. If you want to have a form inside the dialog and want to rely on the `onSubmit` callback, then the `form` tag should be inside the dialog as well. For more details and rationale on this behavior, take a look here: [https://github.com/facebook/react/issues/22470](https://github.com/facebook/react/issues/22470)

All those situations are implemented here, take a look:

{% embed https://codesandbox.io/embed/sticky-header-trap-with-portal-and-native-stuff-xc2prr?file=/src/App.tsx?codemirror=1&hidedevtools=0&view=preview %}


* * *

That is all for today! I hope this journey through positioning and Portals in React was interesting and fun, and teleportation is one of your favorite superpowers now. Things to remember next time you're trying to position elements:

* `position: absolute` positions an element relative to a positioned parent.
* `position: fixed` positions an element relative to the viewport unless a new Containing Block is formed.
* `position: absolute` elements will be clipped inside the `overflow: hidden` elements.
* `position: fixed` elements can escape the `overflow: hidden` problem, but they can't escape the Stacking Context.
* Nothing can escape the Stacking Context. If you are trapped there, it's game over.
* Stacking Context is formed by setting `position` and `z-index`, by setting `translate`, and so many other things
* Portals allow you to easily render some elements, like modal dialogs, outside of their current DOM position so that the Stacking Context doesn't trap them.
* When using Portals, the rules are:
  * What happens in React stays within the React hierarchy.
  * What happens outside of React follows DOM structure rules.

What is your favorite superpower, by the way? 🦸🏻‍♀️

---

Originally published at [https://www.developerway.com](https://www.developerway.com). The website has more articles like this 😉

[Subscribe to the newsletter](https://www.developerway.com), [connect on LinkedIn](https://www.linkedin.com/in/adevnadia/) or [follow on Twitter](https://twitter.com/adevnadia) to get notified as soon as the next article comes out.