23 KiB
title, description, published, authors, tags, attached, order, series
| title | description | published | authors | tags | attached | order | series | ||
|---|---|---|---|---|---|---|---|---|---|
| Portals | 2023-01-01T22:12:03.284Z |
|
|
15 | The Framework Field Guide |
Despite some some UX headaches modals can introduce into an app, they're still a widely used UI element in many applications today.
While building sufficiently useful modals can be a challenging task, a rudimentary modal can be completed even without JavaScript.
While we'll loop back to JavaScript (using React, Angular, and Vue) in a bit, let's use some CSS and HTML in order to build a basic modal:
<div>
<div id="body">
<p>This is some text, pretend it's an app back here</p>
</div>
<div id="modal-container">
<div id="modal">This is a modal</div>
</div>
</div>
<style>
#modal-container {
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
}
#modal {
background: white;
border: 1px solid black;
padding: 1rem;
border-radius: 1rem;
}
</style>
Tada! 🎉 Now we have a fairly basic modal to display whatever HTML we want inside.
But let's say that we keep building out the page. As we do, we might, for example, want to have a footer beneath our main page's content.
<div>
<div id="body" style="min-height: 50vh">
<p>This is some text, pretend it's an app back here</p>
</div>
<div id="modal-container">
<div id="modal">This is a modal</div>
</div>
<footer style="min-height: 50vh">App Name</footer>
</div>
<style>
#modal-container {
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
}
#modal {
background: white;
border: 1px solid black;
padding: 1rem;
border-radius: 1rem;
}
footer {
position: relative;
background: lightblue;
padding: 1rem;
}
</style>
At first glance, this might look like it's been successful, but let's take a look at the rendered output:
Oh dear! Why is the footer rendered above the modal?
Well, my friends, the modal is rendering under the footer due to something called "The Stacking Context".
What is the stacking context?
While the concept of the "Stacking Context" in the DOM is quite complex, here's the gist of it:
While we often think about our browser as displaying a 2-dimensional image as a result of our HTML and CSS, this isn't the case. Take the following code example:
<div id="container">
<div id="blue">Blue</div>
<div id="green">Green</div>
<div id="purple">Purple</div>
</div>
<style>
#container {
display: relative;
}
#blue,
#green,
#purple {
height: 100px;
width: 100px;
position: absolute;
padding: 8px;
color: white;
border: 4px solid black;
border-radius: 4px;
}
#blue {
background: #0f2cbd;
left: 50px;
top: 50px;
}
#green {
background: #007a70;
left: 100px;
top: 100px;
}
#purple {
background: #5f00b2;
left: 150px;
top: 150px;
}
</style>
Here, we have three different boxes that overlap on one another. Given that they overlap, which one do you think takes priority and, at least visually, is on top of the other boxes?
No, really, guess! Stop reading, take a look at the code, and take a guess. 😊
Ready to see the answer?
OK, here it is:
While some CSS pros might assume that purple is the priority due to order in which the CSS is laid out, just like other CSS rules, this isn't what's happening here.
Notice how the purple box seemingly remains on "top" when we re-arrange the CSS rules:
#purple {
background: #5f00b2;
left: 150px;
top: 150px;
}
#green {
background: #007a70;
left: 100px;
top: 100px;
}
#blue {
background: #0f2cbd;
left: 50px;
top: 50px;
}
If changing the CSS order doesn't re-arrange the boxes, then what does?
Well...
Re-arrange HTML Elements to Change the Stacking Order
Let's take the HTML we had before, and re-arrange it a bit:
<div id="container">
<div id="purple">Purple</div>
<div id="green">Green</div>
<div id="blue">Blue</div>
</div>
Now if we look at the box order, we'll see...
Now our boxes have reversed their height order! This is because one of the deciding factors of an element's z position is its relationship to other elements.
Positioned Elements Behave Differently Than Non-Positioned Elements
This is where things get confusing. Take your time with this chapter, it's okay to have to re-read this section multiple times.
While we were using absolutely positioned elements for a simple demo before, let's take a step back and change our elements to be positioned using margin instead:
<div id="container">
<div id="purple">Purple</div>
<div id="green">Green</div>
<div id="blue">Blue</div>
</div>
<style>
#container {
display: relative;
}
#container > div:nth-child(1) {
margin-top: 50px;
margin-left: 50px;
}
#container > div:nth-child(2) {
margin-top: -50px;
margin-left: 100px;
}
#container > div:nth-child(3) {
margin-top: -50px;
margin-left: 150px;
}
#blue,
#green,
#purple {
height: 100px;
width: 100px;
padding: 8px;
color: white;
border: 4px solid black;
border-radius: 4px;
}
#blue {
background: #0f2cbd;
}
#green {
background: #007a70;
}
#purple {
background: #5f00b2;
}
</style>
Looks like a familiar output:
While working on styling, we wanted our green box to move to the left when you hover over it. This is straightforward enough to do using CSS animations, let's add it:
#green {
background: #007a70;
position: relative;
left: 0px;
transition: left 300ms ease-in-out;
}
#green:hover {
left: 20px;
}
While our green button now smoothly moves left when you hover over it, there's a new problem: The green box is now on top of the purple and blue boxes.
This is because positioning an element introduces a "stacked context". This means that our relative positioned element takes priority in the z layer over non-positioned elements.
Understanding more rules of Stacked Contexts
While relative positioning is one way that you can take priority in a stacked context, it's far from the only way to do so. Here's a list of CSS rules that will take priority in a stacked context, from the lowest priority to the highest priority:
- Positioned elements with a negative
z-index - The background and borders of the parent element
- Non-positioned elements
- Elements with a
floatstyle applied - Non-positioned inline elements
- Positioned elements without a
z-indexapplied, or with az-indexof0
So, if we have the following HTML:
<div class="container" style="background: rgba(0, 0, 0, 0.8)">
<div class="box slate" style="position: relative">Slate</div>
<div class="box yellow" style="display: inline-block">Yellow</div>
<div class="box lime" style="float: left">Lime</div>
<div class="box green" style="">Green</div>
<div class="box cyan" style="position: relative; z-index: -1">Cyan</div>
</div>
We would see, from top to bottom:
- A
slatecolored box - A
yellowcolored box - A
limecolored box - A
greencolored box - The
container's background - A
cyancolored box
All of these rules are superseded by the order of the elements within the HTML, as we learned before. For example, with the following HTML:
<div class="container" style="background: rgba(0, 0, 0, 0.8)">
<div class="box slate" style="position: relative">Slate</div>
<div class="box yellow" style="">Yellow</div>
<div class="box lime" style="position: relative">Lime</div>
<div class="box cyan" style="">Cyan</div>
</div>
You would see the following order of elements:
- Lime
- Slate
- Cyan
- Yellow
This is because the lime and slate take priority over yellow and cyan thanks to their relative positioning, but are still in HTML order within the same z level priority and within the same stacking context.
Creating Stacking Contexts
"Welp, that's enough reading in the book today"
You think to yourself. You go lay down and get some sleep. In your dreams, you can still hear the book speaking to you:
[...] are still in HTML order within the same
zlevel priority and within the same stacking context
[...] within the same stacking context
The book repeats itself:
[...] within the same stacking context
You wake up, realize that you don't yet know what that sentence means, and think to yourself:
There's no way this gets even more complicated.
Unfortunately, it does.
At its heart, a stacking context is a group that you can move multiple items up or down the z axis at the same time.
Take the following HTML:
<div class="container">
<div id="top-container" style="position: relative">
<div class="box slate" style="position: relative">Slate</div>
<div class="box yellow" style="">Yellow</div>
</div>
<div id="bottom-container">
<div class="box lime" style="position: relative">Lime</div>
<div class="box cyan" style="">Cyan</div>
</div>
</div>
What order do you think the boxes are going to be in?
The answer is:
- Slate
- Lime
- Cyan
- Yellow
This is because, despite the parent top-container having position: relative, the boxes are still within the same stacking context. This stacking context follows the same ordering rules as outlined before, which means that the positioned slate and lime boxes take z priority over cyan and yellow.
Ready for the twist?
Let's add z-index to our top-container:
<div class="container">
<div style="position: relative; z-index: 1">
<div class="box slate" style="position: relative">Slate</div>
<div class="box yellow" style="">Yellow</div>
</div>
<div>
<div class="box lime" style="position: relative">Lime</div>
<div class="box cyan" style="">Cyan</div>
</div>
</div>
Now what order do you think they'll be in?
- Slate
- Yellow
- Lime
- Cyan
This is because, in reality, what we're ordering here is not the boxes, but instead is the top-container and bottom-container divs, then the boxes, like so:
top-containerslateyellow
bottom-containerlimecyan
The reason this only occurred when we added a z-index to top-container is because that's when a new stacking context was created. When that context was created, we raised it to a higher z axis due to the same ordering rules as before.
Remember, a stacking context is a grouping of elements that move together as a collection when the parent's
zaxis location is changed.
Stacking Contexts are created when:
-
z-indexis applied to a positioned element -
z-indexis applied to a child of agridorflexelement -
Element with an
opacityless than1 -
Element with any of the following properties:
This list is non-exhaustive, but contains most of the highlights of when a stacking context is created.
It's worth mentioning that if a stacking context is created, then the element that created said stacking context is treated with priority z axis ordering.
For example, if you have:
<div>
<div style="position: absolute; top: 0; background: white">Absolute</div>
<div style="opacity: 0.99; background: white">Opacity</div>
</div>
Then it will show "Absolute" above "Opacity", thanks to the order of the HTML sequence; this is all despite positioned elements typically being prioritized above HTML sequencing.
If we remove the opacity: 0.99 from the "Opacity" div, then "Absolute" will be on top.
Stacking Stacking Contexts
While the previous sections have been head scratchers, let's dive into mind melting territory: You can contain stacking contexts within other stacking contexts. 🤯
The Problem with Stacking Contexts
If you want to learn more about the "stacking context", I'd suggest reading through the following resources:
What is a JavaScript portals?
What does any of that CSS stuff have to do with my JavaScript?!
First: Tone. Second: Everything.
Using Local Portals
// TODO: Write this
React
// TODO: Write this
import React, { useMemo, useState } from 'react';
import ReactDOM from 'react-dom';
export default function App() {
const [portalRef, setPortalRef] = useState(null);
const portal = useMemo(() => {
if (!portalRef) return null;
return ReactDOM.createPortal(<div>Hello, world!</div>, portalRef);
}, [portalRef]);
return (
<>
<div
ref={(el) => setPortalRef(el)}
style={{ height: '100px', width: '100px', border: '2px solid black' }}
>
<div />
</div>
{portal}
</>
);
}
Angular
While the other frameworks have something akin to a portal system built into their frameworks' core, Angular does not. Instead, the Angular team maintains a library called "Angular CDK" in order to have shared UI code for utilities such as portals.
To use the Angular CDK, you'll first need to install it into your project:
npm i @angular/cdk
From here, we can import components and utilities directly from the CDK.
import { PortalModule } from '@angular/cdk/portal';
import { DomPortal } from '@angular/cdk/portal';
@Component({
selector: 'my-app',
template: `
<div style="height: 100px; width: 100px; border: 2px solid black;">
<ng-template [cdkPortalOutlet]="domPortal"></ng-template>
</div>
<div #portalContent>Hello, world!</div>
`,
})
class AppComponent implements AfterViewInit {
@ViewChild('portalContent') portalContent: ElementRef<HTMLElement>;
domPortal: DomPortal<any>;
ngAfterViewInit() {
// This is to avoid an:
// "Expression has changed after it was checked"
// error when trying to set domPortal
setTimeout(() => {
this.domPortal = new DomPortal(this.portalContent);
});
}
}
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, PortalModule],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
Rendering ng-template
There might be a flash of the div on screen before our ngAfterViewInit occurs. As such, we may want to use an ng-template:
// TODO: Write
import { PortalModule, TemplatePortal } from '@angular/cdk/portal';
@Component({
selector: 'my-app',
template: `
<div style="height: 100px; width: 100px; border: 2px solid black;">
<ng-template [cdkPortalOutlet]="domPortal"></ng-template>
</div>
<ng-template #portalContent>Hello, this is a template portal</ng-template>
`,
})
class AppComponent implements AfterViewInit {
@ViewChild('portalContent') portalContent: TemplateRef<unknown>;
viewContainerRef = inject(ViewContainerRef);
domPortal: TemplatePortal<any>;
ngAfterViewInit() {
// This is to avoid an:
// "Expression has changed after it was checked"
// error when trying to set domPortal
setTimeout(() => {
this.domPortal = new TemplatePortal(
this.portalContent,
this.viewContainerRef
);
});
}
}
Vue
// TODO: Write this
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
const portalContainerEl = ref(null)
</script>
<template>
<div style="height: 100px; width: 100px; border: 2px solid black">
<div ref="portalContainerEl"></div>
</div>
<div v-if="portalContainerEl">
<Teleport :to="portalContainerEl">Hello, world!</Teleport>
</div>
</template>
We need this v-if in order to ensure that portalContainerEl has already been rendered and is ready to project content.
// TODO: Write this
Application-Wide Portals
// TODO: Write this
React
// TODO: Write this
import React, { useState, createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
// We start by creating a context name
const PortalContext = React.createContext();
function ChildComponent() {
const portalRef = useContext(PortalContext);
if (!portalRef) return null;
return ReactDOM.createPortal(<div>Hello, world!</div>, portalRef);
}
export default function App() {
const [portalRef, setPortalRef] = useState(null);
return (
<PortalContext.Provider value={portalRef}>
<div
ref={(el) => setPortalRef(el)}
style={{ height: '100px', width: '100px', border: '2px solid black' }}
>
<div />
</div>
<ChildComponent />
</PortalContext.Provider>
);
}
Angular
We can use a basic service to share our instance of a Portal between multiple components, parent and child alike.
import { Portal, PortalModule, TemplatePortal } from '@angular/cdk/portal';
@Injectable({
providedIn: 'root',
})
class PortalService {
portal: Portal<any> | null = null;
}
@Component({
selector: 'modal',
template: `
<ng-template #portalContent>Test</ng-template>
`,
})
class ModalComponent implements OnDestroy {
@ViewChild('portalContent') portalContent: TemplateRef<unknown>;
viewContainerRef = inject(ViewContainerRef);
domPortal: TemplatePortal<any>;
portalService = inject(PortalService);
ngAfterViewInit() {
// This is to avoid an:
// "Expression has changed after it was checked"
// error when trying to set domPortal
setTimeout(() => {
this.portalService.portal = new TemplatePortal(
this.portalContent,
this.viewContainerRef
);
});
}
ngOnDestroy() {
this.portalService = null;
}
}
@Component({
selector: 'my-app',
template: `
<div style="height: 100px; width: 100px; border: 2px solid black;" *ngIf="portalService.portal">
<ng-template [cdkPortalOutlet]="portalService.portal"></ng-template>
</div>
<modal></modal>
`,
})
class AppComponent {
portalService = inject(PortalService);
}
Vue
<!-- App.vue -->
<script setup>
import { ref, provide } from 'vue'
import Child from './Child.vue'
const portalContainerEl = ref(null)
provide('portalContainerEl', portalContainerEl)
</script>
<template>
<div style="height: 100px; width: 100px; border: 2px solid black">
<div ref="portalContainerEl"></div>
</div>
<Child />
</template>
// TODO: Write this
HTML-Wide Portals
// TODO: Write
React
// TODO: Write
Alternatively, ReactDOM.createPortal supports passing an arbitrary HTML DOM node, such as html.body:
import React, { useMemo } from 'react';
import ReactDOM from 'react-dom';
function ChildComponent() {
const bodyEl = useMemo(() => {
return document.querySelector('body');
}, []);
return ReactDOM.createPortal(<div>Hello, world!</div>, bodyEl);
}
export default function App() {
return <ChildComponent />;
}
Angular
// TODO: Write
Can't do this
Vue
// TODO: Write
<!-- Child.vue -->
<script setup></script>
<template>
<Teleport to="body">Hello, world!</Teleport>
</template>
<!-- App.vue -->
<script setup>
import Child from './Child.vue'
</script>
<template>
<Child />
</template>








