Managing Stacking Contexts in a “hostile” environment

This article was first published on sitepoint.com (12-16-2014).

Note: this is a proposal I recently wrote for Yahoo!. It has been edited for external audience.


The order in which the rendering tree is painted onto the canvas is described in terms of stacking contexts.

Things get complicated when authors are unfamiliar with the stacking contexts of a page or are simply oblivious of their state.

TL:DR; Proposal, Demo

Interactive Advertising Bureau Guidelines for z-index

From the IAB's perspective, things are pretty straightforward. They have a whole table specifying z-index ranges for almost all types of content, not just ads.

Z-Index Range Content Type Details
< 0 Background Elements  
0 - 4,999 Main Content, Standard Ads Standard ad tags in place with regular content. Includes OBA Self Regulation Message (CLEAR Ad Notice)
5,000 - 1,999,999 Expanding Advertising The entire expandable ad unit should be set within this range
2,000,000 - 2,999,999 Floating Advertising Over The Page ads (OTP's)
3,000,000 - 3,999,999 Pop-up Elements Chat windows, message notifications
4,000,000 - 4,999,999 Non-anchored Floating Elements Survey recruitment panels
5,000,000 - 5,999,999 Expanding Site Navigation Elements Drop down navigation, site warnings, etc. Only the expanding portion of navigation elements should be included on this level.
6,000,000+ Full-page Overlays Full-window Over-the-Page (OTP) ads and Between-the-Page ads IF they cover page content

Source: IAB Display Advertising Guidelines

How big can z-index be anyway?

The CSS specs do not mention an upper limit for z-index but there is a maximum value because of the type of variable used to store that value (a 32-bit signed integer); thus the limit in modern browsers is 2,147,483,647.

Despite this, there are ad guidelines that recommend using 2,147,483,648.

What happens when greater values are used, like this one I found in a page once?

<div id="fresco-thirdparty-tag" style="position:absolute; z-index:10000000000; display:none; top:0; left:0; width:970px; height:250px;">

Modern browsers treat such values as the same as the upper limit. In other words, using 100,000,000,000 is the same as using 2,147,483,647.

Does any of this matter?

The short answer: no, it does not matter one bit.

And that's because stacking contexts are atomic:

Stacking contexts can contain further stacking contexts. A stacking context is atomic from the point of view of its parent stacking context; boxes in other stacking contexts may not come between any of its boxes.

9.9 Layered presentation

In other words, no z-index value can change the position of a box in relation to boxes outside of the stack it belongs to. This is why it is impossible to manage stacks in a predictable manner without knowing about the document tree and the stacking contexts of a page.

So what are z-index guidelines for?

What are those guidelines based on? Source order, as well as z-order of ancestors, play too large a part when it comes to painting the rendering tree — so simply setting z-index ranges cannot be a solution.

For example, if you look at the dynamic demonstration of IAB Z-Index guidelines you'll see that things work nicely because they were designed against a specific set of requirements; but in reality, this is a rather brittle construct as even simple changes could require rethinking most stacking contexts within that page.

For one, imagine styling the "Header" of that demo page with position:fixed (so it stays at the top of the viewport as users scroll down); what z-index value should be used there? Because the header needs to show over the "300x250 Base Ad", its z-index should be at least 2,000,000 high. But note that anything above that value makes the header compete with "Floating Ads" for which the z-index range is 2,000,000 - 2,999,999.

Now imagine we have a search box above the left/right nav, with a dropdown à la "Search Assist"; what z-index value should be used there? Because the dropdown would need to show over the nav, its z-index value should be at least 6,000,000 high. But note that anything above that value makes the dropdown compete with the "Full Page Overlay" for which the z-index range is 6,000,000+ (even if there is little chance that both show at once).

Of course, there are also issues related to "state". Any given box could require that it be positioned differently in the stack depending on its behavior or the behavior of surrounding boxes (dropdowns, flyouts, tooltips, expanding ads, etc.). In such cases, z-index values need to be updated on the fly — see demo further below.

A recipe for disaster

As the IAB demo shows, boxes that do not belong to a stack may be positioned anywhere in relations to other stacks. This is the reason why managing "granular" stacks necessitates centralizing z-index values and enforcing strict rules related to contexts — something that is bound to fail in a multi-team environment where developers may not have access to either the data or the tool (i.e. variables in a pre-processor file).

Proposal

This is about implementing a defensive mechanism to manage stacking contexts through a page. A solution that allows to position a box in relation to other boxes within the same stack or even boxes outside the stack it belongs to.

Explicit contexts

The idea is to style the main boxes of a page to create an explicit "top-level" stack order (see demo). This leverages the atomic nature of stacking contexts and ensures containment at the highest level. We can then predict the behavior of nested boxes regardless of their own z-index.

Dynamic stacks

Once we have top-level stacking contexts, moving a main box up and down the stack suffices to move its nested boxes through that stack as well.

Allowing a change of stacking contexts is crucial because there are times when boxes need to be positioned differently within a stack (see "tooltips" versus "ad" in the demo).

When looking at the demo, pay attention to the number that appears in the black circle at the top of the right rail. That number is the z-index value of the column. The value changes as users interact with elements inside the column. That change allows boxes to switch between stacking contexts (the boxes can move through different stacks).

Declarative switch

We rely on data-* attributes to expose z-index values needed to move any given box through the stack (see code example below).

This way we can manage the ordering of boxes in the stack without knowing anything about the page itself (either its DOM tree nor its stacking contexts).

The same mechanism can be used to reset the z-index of a main box to allow its nested boxes to participate in the main stacking context (i.e. the modal in the demo).

More explicit contexts

Removing a box from the main stacking context allows its nested stacking contexts to become part of the higher stack. This means the z-index of these inner stacks dictate their position in regard to the ordering of the main boxes.

For example, in the demo, when the modal is triggered, the removal of the right rail from the top-level stacking context allows the ad to compete with other main boxes on the page. In other words, if the ad had a z-index of 10+ it would appear on top of the Header.

Because of the risk associated to resetting the z-index of a main box, it is preferable to create explicit stacks for modules we suspect to be styled with a high z-index value. Those can be ads or other modules that page owners do not have control of. Sandboxing these elements prevents boxes from appearing anywhere on the "z" axis. This way, we can still control these boxes even though their ancestor is styled with z-index:auto.

To better understand this issue, please visit this demo page where neither the right rail nor its modules are sandboxed. Scroll down the page a little and hover over the ad. You should see the ad overlapping the Header and the "button" for the modal peeking through the ad.

Both issues are due to identical z-index values (maximum value) that paint elements in the rendering tree according to their positioning in the markup. The Header shows behind the ad because it comes first. The modal button shows in front of the ad because it comes last.

Implementation

This solution requires sharing a simple setup and a common vocabulary across grids (when there are different grids involved as it would be the case for Yahoo sites for example — Finance, Sports, Home Page, Answers, etc.).

The "4-step program":

  1. All main boxes on the page must be positioned and styled with a z-index (other than auto)
  2. Each one of these boxes must be identified via a common class: stacking-context
  3. Those same boxes must have data-zindex-max and data-zindex-top attributes containing the z-index values necessary to move a box up in the stack.
  4. The class inner-stack is applied to the wrapper of any module susceptible to be styled with a high z-index value.

For example:

<div id="right-rail" class="stacking-context" data-zindex-max="5"  data-zindex-top="10">
    <div class="inner-stack">
        <div id="fresco-thirdparty-tag" style="position:absolute;z-index:10000000000;">

data-zindex-max
This attribute is used to declare the highest z-order the box is allowed to be styled with. For example, this could be a column meant to always show behind the Header.

data-zindex-top
This attribute is used to declare the highest z-order set within the page. For example, this would be the position of the Header inside the stack. Any element styled with that z-index value would appear in front of the Header (as long as that element comes later in the source code)

.inner-stack
This class is used to keep modules in check by preventing their own z-index to come into play. That z-index value is set via CSS by the page/grid owner.

Advantages of this solution

It reduces the risk of breakage
By leveraging the atomic nature of stacking contexts, we can sandbox modules, making their z-index values irrelevant in relation to other boxes on the page.

It is predictable
No more guessing game; authors do not need to know anything about the document tree or its stacking contexts to be able to move boxes through stacks.

It centralizes responsibilities
Everything lies in the hand of the team responsible for the page itself - the team that knows everything about the document — its tree, its stacking context, etc.

Further thinking

In projects that use a single grid as well as in projects that share a common one, things could be made more declarative by using a preset of rules to swap z-index based on meaningful classes. For example:

Code Example

Below is the JavaScript used in the main demo page. This script is responsible for switching the z-index value of the column that holds the ad and the "modal" button. It also "frees" these elements by resetting the z-index value set on their wrapper. It is the combination of both of these styles that allows these elements to behave as expected even though a very high z-index is applied to them.

(function(document) {
    function findAncestor(el, cls) {
        while ((el = el.parentElement) &amp;&amp; !el.classList.contains(cls));
        return el;
    }
    // constants
    var AUTO = 'auto',
    CSS_MODAL_ON = 'modal-on',
    CSS_INNER_STACK = 'inner-stack',
    DOC = document,
    WIN = DOC.defaultView,
    HTML = DOC.documentElement,
    // elements
    theAd = DOC.getElementById('ad'),
    theAncestorStack = findAncestor(theAd, 'stacking-context'),
    theInnerStack = DOC.getElementsByClassName(CSS_INNER_STACK)[0],
    theModal = DOC.getElementById('modal'),
    theStackUpdate = DOC.getElementById('stackUpdate'),
    // globals
    theInnerStackAncestor,
    zIndexAncestor = WIN.getComputedStyle(theAncestorStack, null).zIndex,
    zIndexCurrent, // will be assigned
    zIndexInnerStack = WIN.getComputedStyle(theInnerStack, null).zIndex,
    zIndexMax = theAncestorStack.getAttribute('data-zindex-max'),
    zIndexTop = theAncestorStack.getAttribute('data-zindex-top');
    function switchStackingContext (e) {
        // we move the ancestor through the stack according to zIndexMax value (highest value allowed for that ancestor box)
        if (WIN.getComputedStyle(theAncestorStack, null).zIndex === zIndexMax) {
            theAncestorStack.style.zIndex = zIndexAncestor;
        } else {
            theAncestorStack.style.zIndex = zIndexMax;
        }
        // we get the ancestor that wraps boxes with potential high/crazy z-index
        theInnerStackAncestor = findAncestor(e.target, CSS_INNER_STACK);
        // we check for the actual value before we change it
        zIndexCurrent = WIN.getComputedStyle(theInnerStackAncestor, null).zIndex;
        // we reset the z-index from the parent ancestor allowing the box to move up (wherever it wants to)
        if (zIndexCurrent !== AUTO) {
            theInnerStackAncestor.style.zIndex = AUTO;
        } else {
            theInnerStackAncestor.style.zIndex = zIndexInnerStack;
        }
        // we update the value on the page
        theStackUpdate.innerText = theAncestorStack.style.zIndex;
    }
    function resetStackingContext (e) {
        // we reset the z-index of the ancestor to free the inner box
        if (WIN.getComputedStyle(theAncestorStack, null).zIndex === AUTO) {
            theAncestorStack.style.zIndex = zIndexAncestor;
        } else {
            theAncestorStack.style.zIndex = AUTO;
        }
        // we get the ancestor that wraps boxes with potential high/crazy z-index
        theInnerStackAncestor = findAncestor(e.target, CSS_INNER_STACK);
        // we check for the actual value before we change it
        zIndexCurrent = WIN.getComputedStyle(theInnerStackAncestor, null).zIndex;
        // we reset the z-index from the parent ancestor allowing the box to move up (wherever it wants to)
        if (zIndexCurrent !== AUTO) {
            theInnerStackAncestor.style.zIndex = AUTO;
        } else {
            theInnerStackAncestor.style.zIndex = zIndexInnerStack;
        }
        // we toggle this class to contextually style the modal (nothing to do with the solution per se)
        HTML.classList.toggle(CSS_MODAL_ON);
        // we update the value on the page
        theStackUpdate.innerHTML = theAncestorStack.style.zIndex;
    }
    theAd.addEventListener('mouseenter', switchStackingContext, false);
    theAd.addEventListener('mouseleave', switchStackingContext, false);
    theModal.addEventListener('click', resetStackingContext, false);
}(document));