Nadia Makarevich

Teleportation in React: Positioning, Stacking Context, and Portals

Looking into how CSS positioning works, what is Stacking Context, how to escape overflow:hidden with CSS, why we can't do everything with CSS and need Portals, how Portals work

Teleportation in React: Positioning, Stacking Context, and Portals

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:

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).

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:

// 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:

.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.

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.

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.

Until Stacking Context rules kicks in.

Understanding Stacking Context

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:

<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:

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.

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

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.

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.

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.

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.

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.

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.

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:

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.

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:

.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:

.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:

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!

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").
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.

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.

See the app below, which implements all of this:

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.

// 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.

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

All those situations are implemented here, take a look:


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? 🦸🏻‍♀️