Files
unicorn-utterances/content/blog/css-stacking-context/index.md
2022-09-05 02:56:45 -07:00

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
crutchcorn
webdev
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>

A modal that's focused in the foreground, with a semi-transparent black background that dims all other elements

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:

The footer is on top of the modal, instead of beneath it

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:

The three colored boxes are, in order from top to bottom: Purple, green, then blue.

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;
}

The three colored boxes remain in the same order from top to bottom: Purple, green, then blue.

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

The box orders have flipped! Now, in order from top to bottom, it's: Blue, green, then purple.

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:

The same exact three colored boxes in the order from top to bottom: Purple, green, then blue.

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.

The same colored boxes but green appears to be on top

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 float style applied
  • Non-positioned inline elements
  • Positioned elements without a z-index applied, or with a z-index of 0

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 slate colored box
  • A yellow colored box
  • A lime colored box
  • A green colored box
  • The container's background
  • A cyan colored box

The boxes in order as mentioned above

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

A square of blocks demonstrating the order as laid out above

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 z level 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?

Colored boxes in the order as described below

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?

The colored boxes reordered in the manner outlined below

  • 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-container
    • slate
    • yellow
  • bottom-container
    • lime
    • cyan

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 z axis location is changed.

Stacking Contexts are created when:

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>