Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

CSS  Module 9 – Normal flow, positioning and stacking

Stacking contexts and z-index

Why z-index: 9999 can still lose: the painting order, what creates a stacking context, isolation, popovers in the top layer and a small z-index scale.

  • Intermediate
  • 30 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Predict the painting order of boxes within and across stacking contexts
  • List what creates a stacking context and use isolation on purpose
  • Debug a dropdown that disappears behind another component
  • Move menus and dialogs into the top layer with popover and showModal()
  • Organise z-index values as a small named scale

Before you start

What you’ll learn · Before you start

What you will learn

  • Predict the painting order of boxes within and across stacking contexts
  • List what creates a stacking context and use isolation on purpose
  • Debug a dropdown that disappears behind another component
  • Move menus and dialogs into the top layer with popover and showModal()
  • Organise z-index values as a small named scale

Before you start

On this page

When boxes overlap, the browser paints them in a fixed order, and the box painted last is the one on top. z-index changes that order, but only between boxes that share a stacking context: a group of boxes painted as one unit, at one level of its parent’s stack. Every page starts with one, at the root, and many properties create more, among them position with a z-index, opacity below 1, transform and isolation. A box can never be painted above something that its stacking context is below, whatever its own z-index, which is why z-index: 9999 can lose to z-index: 2. This lesson checks every case with document.elementFromPoint(), which returns the topmost box at a point, in Chromium 153, Firefox 155 and WebKit 26.6. Then it lists what creates a stacking context, and shows how popovers and modal dialogs rise above all of them in the top layer.

The painting order inside one stacking context

The seven painting layers of a stacking context, from its own background up to children with a positive z-index.1. The stacking context's own background and border(the bottom of the stack)2. Child stacking contexts with a negative z-index,most negative first3. Boxes in normal flow that are not positioned:their backgrounds and borders, in tree order4. Floats that are not positioned5. Inline content: text, images and inline-blocks6. Positioned boxes with z-index: auto or 0, and contextsmade by opacity, transform and the like, in tree order7. Child stacking contexts with a positive z-index,lowest first (the top of the stack)painted over bypainted over bypainted over bypainted over bypainted over bypainted over by

How one stacking context paints its contents, bottom to top

Text description of the diagram

The diagram is a column of seven layers. Each layer is painted over the one before it, so the first is at the bottom of the stack and the last at the top.

  1. The stacking context's own background and border.
  2. Child stacking contexts with a negative z-index, the most negative first.
  3. Boxes in normal flow that are not positioned: their backgrounds and borders, in tree order.
  4. Floats that are not positioned.
  5. Inline content: text, images and inline-blocks.
  6. Positioned boxes with z-index auto or 0, and stacking contexts made by properties such as opacity and transform, in tree order.
  7. Child stacking contexts with a positive z-index, the lowest first. This is the top of the stack.

A child stacking context is painted whole, with all of its own contents, at its place in this list.

Inside a stacking context the browser paints in seven layers, from the bottom up, and inside each layer it follows the order of the HTML. Each child stacking context is painted whole, with everything inside it, at its place in that list, like a sheet of paper with its own drawings on it: nothing outside can slide between the drawings on that sheet.

Three consequences explain most surprises. Positioned boxes paint above boxes that aren’t positioned, even with z-index: auto and whatever the order of the HTML. A negative z-index puts a box below the boxes in normal flow, including its own parent, unless the parent is itself a stacking context. And z-index works only on positioned boxes and on flex and grid items; on any other box it does nothing. The paint-order page checks each of these:

Negative z-index, isolation, flex items and positioned boxes HTML · paint-order.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Negative z-index, isolation, flex items and positioned boxes without a z-index</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .case { block-size: 140px; }
    .parent { position: relative; inline-size: 200px; block-size: 100px; background: #ccf; }
    .child { position: absolute; z-index: -1; inset-block-start: 20px; inset-inline-start: 20px;
             inline-size: 100px; block-size: 50px; background: #f99; }
    #isolated .parent { isolation: isolate; }

    .row { display: flex; }
    .row div { inline-size: 100px; block-size: 60px; }
    .row .a { margin-inline-end: -50px; background: #fcc; }  /* overlaps b by 50px */
    .row .b { background: #ccf; }
    #flex-z .a { z-index: 1; }                               /* no position: z-index works on flex items */

    .positioned { position: relative; inline-size: 200px; block-size: 60px; background: #ddf; }
    .positioned .inner { inline-size: 120px; block-size: 40px; background: #dfd; }
    .later { margin-block-start: -40px; inline-size: 200px; block-size: 60px; background: #fdd; } /* static, after it */

    .faded { opacity: 0.95; block-size: 50px; background: #dde; }      /* not positioned */
    .faded .menu { position: absolute; z-index: 1; inline-size: 150px; block-size: 90px; background: #fd8; }
    .plain-card { inline-size: 240px; block-size: 60px; background: #cfc; } /* static, after the header */
  </style>
</head>
<body>
  <div class="case" id="negative"><div class="parent"><div class="child"></div></div></div>
  <div class="case" id="isolated"><div class="parent"><div class="child"></div></div></div>
  <div class="case" id="flex-plain"><div class="row"><div class="a"></div><div class="b"></div></div></div>
  <div class="case" id="flex-z"><div class="row"><div class="a"></div><div class="b"></div></div></div>
  <div class="case" id="auto"><div class="positioned"><div class="inner"></div></div><div class="later"></div></div>
  <div class="case" id="faded-header"><div class="faded"><div class="menu"></div></div><div class="plain-card"></div></div>
  <script>
    const name = (el) => el ? (el.className || el.tagName.toLowerCase()) : 'nothing';
    const at = (el, dx, dy) => { const r = el.getBoundingClientRect(); return name(document.elementFromPoint(r.left + dx, r.top + dy)); };
    for (const id of ['negative', 'isolated']) {
      const parent = document.querySelector(`#${id} .parent`);
      console.log(`${id}: over the child, the top box is "${at(parent, 60, 40)}"`);
    }
    for (const id of ['flex-plain', 'flex-z']) {
      const a = document.querySelector(`#${id} .a`);
      console.log(`${id}: where a and b overlap, "${at(a, 70, 30)}" is on top`);
    }
    const inner = document.querySelector('#auto .inner');
    console.log(`auto: where the static child of the positioned box meets the later static box, "${at(inner, 60, 30)}" is on top`);
    const card = document.querySelector('#faded-header .plain-card');
    console.log(`faded-header: where the faded header's menu meets the later plain card, "${at(card, 40, 20)}" is on top`);
  </script>
</body>
</html>
Case On top
a child at z-index: -1 in a relative parent the parent, over the child
the same parent with isolation: isolate the child, over the parent’s background
two overlapping flex items the second
z-index: 1 on the first, with no position the first
a positioned box’s plain child, and a later plain box the positioned box’s child
a menu in a header with opacity: 0.95, and a later card the menu

All three engines agreed on every case. The last row needs a word: a stacking context made by opacity, a transform or a filter is painted with the positioned boxes of layer 6, so fading a header also lifted it, menu and all, above the plain card that came after it.

Why z-index: 9999 loses

The explorer has a header with position: relative, a menu inside it with z-index: 10, and a card after it with position: relative and z-index: 1, overlapping the menu. Its checkboxes toggle styles on the header and the card, and it reports which box is on top where the two overlap:

A dropdown, a card, and the styles that change which is on top HTML · explorer.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A dropdown, a card, and the styles that change which is on top</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .header { position: relative; block-size: 50px; background: #dde; }
    .menu { position: absolute; inset-block-start: 40px; inset-inline-start: 20px; z-index: 10;
            inline-size: 160px; block-size: 120px; background: #fd8; }
    .card { position: relative; z-index: 1; margin-inline-start: 60px; inline-size: 240px; block-size: 100px; background: #cfc; }
    /* The toggles: each one adds a class to the header or the card. */
    .header.opacity { opacity: 0.95; }
    .header.transform { transform: translateY(0); }
    .header.isolate { isolation: isolate; }
    .header.raised { z-index: 2; }
    .card.opacity { opacity: 0.95; }
    fieldset { margin-block-start: 140px; }
  </style>
</head>
<body>
  <div class="header" id="header">Header <div class="menu" id="menu">Menu, z-index: 10</div></div>
  <div class="card" id="card">Card, z-index: 1</div>
  <fieldset>
    <legend>Styles</legend>
    <label><input type="checkbox" data-target="header" data-class="opacity"> header opacity: 0.95</label>
    <label><input type="checkbox" data-target="header" data-class="transform"> header transform</label>
    <label><input type="checkbox" data-target="header" data-class="isolate"> header isolation: isolate</label>
    <label><input type="checkbox" data-target="header" data-class="raised"> header z-index: 2</label>
    <label><input type="checkbox" data-target="card" data-class="opacity"> card opacity: 0.95</label>
  </fieldset>
  <p id="result" aria-live="polite"></p>
  <script>
    const header = document.getElementById('header');
    const card = document.getElementById('card');
    // The point where the menu and the card overlap.
    const onTop = () => {
      const c = card.getBoundingClientRect();
      const hit = document.elementFromPoint(c.left + 40, c.top + 20);
      return hit && hit.closest('#menu') ? 'the menu' : hit && hit.closest('#card') ? 'the card' : 'something else';
    };
    const show = () => { document.getElementById('result').textContent = `On top where they overlap: ${onTop()}.`; };
    for (const box of document.querySelectorAll('input[type="checkbox"]')) {
      box.addEventListener('change', () => {
        document.getElementById(box.dataset.target).classList.toggle(box.dataset.class, box.checked);
        show();
      });
    }
    show();
    // Every state the lesson's table lists, logged once.
    const states = [[], ['opacity'], ['transform'], ['isolate'], ['isolate', 'raised'], ['transform', 'raised']];
    for (const classes of states) {
      header.className = ['header', ...classes].join(' ');
      console.log(`header ${classes.length ? classes.join(' + ') : 'plain'}: ${onTop()} on top`);
    }
    header.className = 'header';
    card.classList.add('opacity');
    console.log(`card opacity: ${onTop()} on top`);
    card.classList.remove('opacity');
  </script>
</body>
</html>
Styles added On top where they overlap
none the menu
header opacity: 0.95 the card
header transform: translateY(0) the card
header isolation: isolate the card
header isolation: isolate and z-index: 2 the menu
header transform and z-index: 2 the menu
card opacity: 0.95 the menu

With no styles added, the header is positioned but has no z-index, so it is not a stacking context, and the menu’s z-index: 10 competes in the root context against the card’s 1, and wins. Opacity, a transform or isolation each made the header a stacking context at level 0: the menu’s 10 now counted only inside the header, and the card, at level 1, was painted after the whole header. Giving the header’s context a level above the card’s, z-index: 2, lifted the menu with it. Opacity on the card changed nothing, because the card was already a context at level 1.

So when a dropdown hides behind something, the question is never how high its z-index is. It is which stacking contexts the two boxes are in, and how those contexts compare.

What creates a stacking context

The triggers page tests one style at a time with a child that has z-index: -1, which is painted above its parent’s background only when the parent is a stacking context:

Which styles create a stacking context, one at a time HTML · triggers.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Which styles create a stacking context, tested one at a time</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .holder { block-size: 64px; }
    .holder.flex { display: flex; }
    .holder.grid { display: grid; }
    /* The test: a child with z-index: -1 is painted above its parent's background only when the parent is a
       stacking context; otherwise it goes behind the parent, into the stacking context around it. */
    .parent { inline-size: 200px; block-size: 60px; background: #ccf; }
    .child { position: absolute; z-index: -1; inline-size: 100px; block-size: 40px; background: #f99; }
  </style>
</head>
<body>
  <div id="all"></div>
  <script>
    // [the parent's style, the display of the box around it]
    const cases = [
      ['position: relative'], ['position: relative; z-index: 0'], ['position: absolute; z-index: 1; width: 200px'],
      ['position: fixed; bottom: 0; right: 0'], ['position: sticky'], ['z-index: 0', 'flex'], ['z-index: 0', 'grid'],
      ['z-index: 5'], ['opacity: 0.99'], ['opacity: 1'], ['mix-blend-mode: multiply'],
      ['transform: translateX(0)'], ['translate: 0'], ['rotate: 0deg'], ['scale: 1'], ['filter: blur(0)'],
      ['backdrop-filter: blur(0)'], ['perspective: 500px'], ['clip-path: inset(0)'],
      ['mask-image: linear-gradient(black, black)'], ['isolation: isolate'], ['contain: layout'], ['contain: paint'],
      ['contain: strict; height: 60px'], ['contain: content'], ['contain: size; height: 60px'],
      ['content-visibility: auto'], ['view-transition-name: card'], ['will-change: opacity'], ['will-change: top'],
      ['overflow: hidden'], ['container-type: inline-size'],
    ];
    const all = document.getElementById('all');
    const rows = cases.map(([style, display]) => {
      const holder = document.createElement('div');
      holder.className = `holder ${display ?? ''}`;
      const parent = document.createElement('div');
      parent.className = 'parent';
      parent.setAttribute('style', style);
      const child = document.createElement('div');
      child.className = 'child';
      parent.append(child);
      holder.append(parent);
      all.append(holder);
      return { style: display ? `${style} (a ${display} item)` : style, parent, child };
    });
    requestAnimationFrame(() => requestAnimationFrame(() => {
      for (const { style, parent, child } of rows) {
        child.scrollIntoView({ block: 'center' });
        const r = child.getBoundingClientRect();
        const top = document.elementFromPoint(r.left + 10, r.top + 10);
        const verdict = top === child ? 'a stacking context' : top === parent ? 'not a stacking context' : 'unclear';
        console.log(`${style.padEnd(42)} ${verdict}`);
      }
    }));
  </script>
</body>
</html>

These made a stacking context in all three engines; the root and the top layer are stacking contexts by definition:

  • the root element;
  • position: relative or absolute with a z-index other than auto;
  • position: fixed and position: sticky, always, even with no inset;
  • a flex or grid item with a z-index other than auto, with or without a position;
  • opacity below 1, and a mix-blend-mode other than normal;
  • transform, translate, rotate, scale, filter, backdrop-filter, perspective, clip-path or mask-image with any value other than none;
  • isolation: isolate;
  • contain: layout, paint, strict or content, and content-visibility: auto;
  • a view-transition-name other than none, and a will-change that names one of these properties;
  • an element in the top layer, and its ::backdrop.

Not on the list: position: relative with no z-index, overflow: hidden, opacity: 1, contain: size, will-change: top, and container-type, which some references still include. CSS Conditional 5 gives a size container style and size containment only, and none of the three engines made it a stacking context. The program applies the list to a few boxes:

Which styles make a stacking context JavaScript · contexts.mjs
// Which styles make a box a stacking context, and why (CSS 2 Appendix E, CSS Position 3, Color 4, Transforms 1,
// Compositing 1, Containment 2 and Will Change 1), checked in Chromium, Firefox and WebKit. A style lists a box's
// CSS as strings; parentDisplay matters because flex and grid items obey z-index without a position.

const NOT_NONE = ['transform', 'translate', 'rotate', 'scale', 'filter', 'backdrop-filter', 'perspective',
  'clip-path', 'mask-image'];
const WILL_CHANGE = new Set(['opacity', 'transform', 'translate', 'rotate', 'scale', 'filter', 'backdrop-filter',
  'perspective', 'clip-path', 'mask-image', 'isolation', 'mix-blend-mode']);

function stackingContextReason(style, parentDisplay = 'block') {
  const position = style.position ?? 'static';
  const z = style['z-index'] ?? 'auto';
  if (position === 'fixed' || position === 'sticky') return `position: ${position}, always`;
  if (z !== 'auto' && position !== 'static') return `position: ${position} with z-index: ${z}`;
  if (z !== 'auto' && /^(inline-)?(flex|grid)$/.test(parentDisplay)) return `a ${parentDisplay} item with z-index: ${z}`;
  if (Number(style.opacity ?? '1') < 1) return `opacity: ${style.opacity}`;
  for (const p of NOT_NONE) if ((style[p] ?? 'none') !== 'none') return `${p}: ${style[p]}`;
  if ((style['mix-blend-mode'] ?? 'normal') !== 'normal') return `mix-blend-mode: ${style['mix-blend-mode']}`;
  if (style.isolation === 'isolate') return 'isolation: isolate';
  const contain = (style.contain ?? 'none').split(/\s+/);
  if (contain.some((w) => ['layout', 'paint', 'strict', 'content'].includes(w))) return `contain: ${style.contain}`;
  if (style['content-visibility'] === 'auto') return 'content-visibility: auto';
  const willChange = (style['will-change'] ?? 'auto').split(',').map((w) => w.trim());
  if (willChange.some((w) => WILL_CHANGE.has(w))) return `will-change: ${style['will-change']}`;
  return null;
}

const boxes = [
  [{ position: 'relative' }],
  [{ position: 'relative', 'z-index': '0' }],
  [{ position: 'sticky' }],
  [{ 'z-index': '5' }],
  [{ 'z-index': '1' }, 'flex'],
  [{ opacity: '0.99' }],
  [{ opacity: '1' }],
  [{ transform: 'translateZ(0)' }],
  [{ isolation: 'isolate' }],
  [{ overflow: 'hidden' }],
  [{ 'container-type': 'inline-size' }],
  [{ 'will-change': 'opacity' }],
  [{ 'will-change': 'top' }],
  [{ contain: 'size' }],
];
for (const [style, parent] of boxes) {
  const css = Object.entries(style).map(([k, v]) => `${k}: ${v}`).join('; ');
  const where = parent ? ` (in a ${parent} container)` : '';
  const reason = stackingContextReason(style, parent);
  console.log(`${(css + where).padEnd(42)} ${reason ? `context: ${reason}` : 'no context'}`);
}

Output

position: relative                         no context
position: relative; z-index: 0             context: position: relative with z-index: 0
position: sticky                           context: position: sticky, always
z-index: 5                                 no context
z-index: 1 (in a flex container)           context: a flex item with z-index: 1
opacity: 0.99                              context: opacity: 0.99
opacity: 1                                 no context
transform: translateZ(0)                   context: transform: translateZ(0)
isolation: isolate                         context: isolation: isolate
overflow: hidden                           no context
container-type: inline-size                no context
will-change: opacity                       context: will-change: opacity
will-change: top                           no context
contain: size                              no context

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node contexts.mjs

isolation: isolate, a context on purpose

isolation: isolate creates a stacking context and nothing else: no containing block for fixed boxes, as the positioning lesson measured, no clipping and no visual change. That makes it the tool for components. Give a component’s root isolation: isolate, and the z-index values inside it, say a badge at 1 over an image at 0, are compared only with each other. They can’t cover a dropdown from elsewhere on the page, and the page’s layers never need to know about them. It also keeps decorations with a negative z-index above the component’s own background, as the isolated parent on the paint-order page showed. isolation is Baseline widely available.

Rising above everything: the top layer

Some things must appear above everything else: menus, dialogs, toasts and tooltips. The top layer sits above the whole document, outside every stacking context. Its elements are painted as if they were siblings of the root element, an ancestor’s overflow can’t clip them, and they need no z-index. Three features put an element there: dialog.showModal(), the popover attribute, shown with showPopover() or a button with popovertarget, and the Fullscreen API. A fixed element in the top layer is placed against the viewport, so no ancestor’s transform captures it either.

The popover page builds the worst case: a header with overflow: hidden, a transform and z-index: 1, a dropdown inside it with z-index: 9999, and a section after it with z-index: 2. It probes a point inside the menu, 60 px from its top and below the header:

A dropdown trapped in its header, and the same menu as a popover HTML · popover-escape.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A dropdown trapped in its header, and the same menu as a popover</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    /* A header that clips, is animated into place and has a z-index: three traps at once. */
    .header { position: relative; z-index: 1; overflow: hidden; transform: translateY(0); block-size: 56px; background: #dde; }
    .section { position: relative; z-index: 2; block-size: 300px; background: #cfc; }
    .dropdown { position: absolute; inset-block-start: 48px; inset-inline-start: 16px; z-index: 9999;
                inline-size: 200px; block-size: 150px; margin: 0; background: #fd8; }
    /* The popover version: the browser shows it in the top layer. Placed with fixed insets here. */
    [popover].dropdown { position: fixed; inset: auto; inset-block-start: 48px; inset-inline-start: 16px;
                         border: 0; padding: 0; }
  </style>
</head>
<body>
  <div class="header">
    <button type="button" id="menu-button" popovertarget="menu">Menu</button>
    <div class="dropdown" id="trapped">Trapped menu</div>
    <div class="dropdown" id="menu" popover>Popover menu</div>
  </div>
  <div class="section">The next section, z-index: 2</div>
  <script>
    const name = (el) => el ? (el.id || el.className || el.tagName.toLowerCase()) : 'nothing';
    // A point 60px below the top of the menus: inside the menus, but below the 56px header.
    const probe = () => name(document.elementFromPoint(60, 108));
    console.log(`trapped dropdown: at the probe point the top box is "${probe()}"`);
    document.getElementById('trapped').remove();
    document.getElementById('menu').showPopover();
    console.log(`popover: open ${document.getElementById('menu').matches(':popover-open')}, the top box is "${probe()}"`);
  </script>
</body>
</html>
The menu is The top box at the probe point
an absolutely positioned dropdown with z-index: 9999 the next section: the menu was clipped and covered
a popover the popover menu

<dialog> with showModal() is Baseline widely available, and the popover attribute is Baseline newly available. The browser’s own styles centre a popover in the viewport, so a menu still has to be placed next to its button, with a few lines of script or with anchor positioning, which has limited availability so far.

A z-index scale instead of an arms race

Most z-index bugs end with a bigger number, and the next bug with a bigger one still. A short scale of named values, used only by the few layers of a page, ends that. Components isolate themselves, and the top layer takes dialogs, popovers and toasts, so a page rarely needs more than four levels:

A named z-index scale CSS · z-scale.css
/* A small, named z-index scale. Every value is used by one layer of the page, and nothing needs 9999. */
:root {
  --z-raised: 1;      /* a card lifted on hover */
  --z-dropdown: 10;   /* menus inside the header */
  --z-sticky: 20;     /* the sticky header and its menus */
  --z-overlay: 30;    /* a side panel that slides over the page */
}

.site-header {
  position: sticky;
  inset-block-start: 0;
  z-index: var(--z-sticky);   /* the header's context sits above the content, so its menus do too */
}
.site-header .menu {
  position: absolute;
  z-index: var(--z-dropdown); /* compared only with other boxes inside the header */
}

/* Each component is its own stacking context, so the z-index values inside it can't leak out. */
.card {
  isolation: isolate;
}

/* Modal dialogs, popovers and toasts go to the top layer with showModal() or popover and need no z-index. */

Keeping every value at 100 or below is a simple rule for a stylesheet linter, and every number then has a reason that is written next to it.

Debugging a hidden dropdown, step by step

  1. Find what covers it. Call document.elementFromPoint(x, y) at a point where the menu should be, or point at the spot with the element picker of the browser’s developer tools.
  2. List the stacking contexts of both boxes. Walk up from the menu and from the box that covers it, and note every ancestor that creates a stacking context, with its z-index.
  3. Compare where the paths meet. Find the nearest stacking context the two share, and compare the two contexts directly inside it, one on each path: the higher level wins, and at the same level the later one in the HTML wins.
  4. Fix it at that level. Raise the context that holds the menu, remove a context the header doesn’t need, such as a leftover transform, or move the menu to the top layer. Raising the menu’s own z-index never helps once a lower context holds it.
HTML, CSS & JS Playground (Online Editor) Paste the explorer into the HTML pane and tick the styles to watch the menu move.

Practise: predict the stacking order

The exercise asks for the painting order of whole pages, and its tests are the cases measured in this lesson.

Exercise · Hard · JavaScript

Predict the painting order of overlapping boxes

When two boxes overlap, the one painted last is on top. Write paintOrder(root) in paint.mjs and export it: it returns the ids of all the boxes of a page, from the one painted first (the bottom) to the one painted last (the top).

A box is { id, style = {}, children = [] }, where style holds CSS properties as strings, such as { position: 'relative', 'z-index': '2' }. root is the html element. Every box is a block in normal flow: there are no floats and no text to worry about.

  • Stacking contexts. The root is one. A child box is one when it has position: fixed or sticky; a position other than static with a z-index other than auto; a z-index other than auto as an item of a parent whose display is flex, inline-flex, grid or inline-grid; opacity below 1; any of transform, translate, rotate, scale, filter, backdrop-filter, perspective, clip-path or mask-image other than none; mix-blend-mode other than normal; isolation: isolate; contain with layout, paint, strict or content; content-visibility: auto; or a will-change naming one of those properties. The contexts.mjs example of the lesson is a good start.
  • Levels. A context's level is its z-index as a number when it is positioned or a flex or grid item, and 0 otherwise.
  • Painting a context. First the context itself; then its child contexts with a negative level, lowest first; then the boxes that are neither positioned nor contexts, in tree order; then, in tree order, positioned boxes with z-index: auto and contexts of level 0; then contexts with a positive level, lowest first. Equal levels keep tree order. Each child context is painted whole, with everything inside it, at its place in that order.
  • **A positioned box with z-index: auto** is not a context: it is painted at its place in the fourth group, and the descendants that are neither positioned nor contexts are painted right after it, but its positioned descendants and its descendant contexts join the lists of the context around it.

The sample tests import the function from paint.mjs and run in your browser.

Starter code · paint.mjs

/** The ids of every box, in the order the browser paints them: the bottom first, the top last. */
export function paintOrder(root) {
  // Replace this line with your code.
  return [root.id];
}
The sample tests · paint.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { paintOrder } from './paint.mjs';

const box = (id, style = {}, children = []) => ({ id, style, children });
const top = (order, a, b) => (order.indexOf(a) > order.indexOf(b) ? a : b);

test('boxes in normal flow paint in tree order', () => {
  assert.deepEqual(paintOrder(box('root', {}, [box('a'), box('b', {}, [box('c')])])), ['root', 'a', 'b', 'c']);
});

test('z-index orders positioned siblings', () => {
  const page = box('root', {}, [box('a', { position: 'relative', 'z-index': '2' }), box('b', { position: 'relative', 'z-index': '1' })]);
  assert.deepEqual(paintOrder(page), ['root', 'b', 'a']);
});

test('a menu with z-index 9999 stays under a card when its header is a lower context', () => {
  const page = box('root', {}, [
    box('header', { position: 'relative', 'z-index': '1' }, [box('menu', { position: 'absolute', 'z-index': '9999' })]),
    box('card', { position: 'relative', 'z-index': '2' }),
  ]);
  assert.deepEqual(paintOrder(page), ['root', 'header', 'menu', 'card']);
});

test('without a context on the header, the menu competes with the card directly', () => {
  const page = box('root', {}, [
    box('header', { position: 'relative' }, [box('menu', { position: 'absolute', 'z-index': '9999' })]),
    box('card', { position: 'relative', 'z-index': '2' }),
  ]);
  assert.deepEqual(paintOrder(page), ['root', 'header', 'card', 'menu']);
});

test('opacity and transform make contexts at level 0', () => {
  const faded = box('root', {}, [
    box('header', { opacity: '0.9' }, [box('menu', { position: 'absolute', 'z-index': '9999' })]),
    box('card'),
  ]);
  assert.deepEqual(paintOrder(faded), ['root', 'card', 'header', 'menu']);
  const moved = box('root', {}, [
    box('header', { transform: 'translateY(0)' }, [box('menu', { position: 'absolute', 'z-index': '9999' })]),
    box('card', { position: 'relative' }),
  ]);
  assert.equal(top(paintOrder(moved), 'menu', 'card'), 'card');
});

test('a negative z-index goes behind its parent unless the parent is a context', () => {
  const plain = box('root', {}, [box('parent', { position: 'relative' }, [box('child', { position: 'absolute', 'z-index': '-1' })])]);
  assert.deepEqual(paintOrder(plain), ['root', 'child', 'parent']);
  const isolated = box('root', {}, [box('parent', { position: 'relative', isolation: 'isolate' }, [box('child', { position: 'absolute', 'z-index': '-1' })])]);
  assert.deepEqual(paintOrder(isolated), ['root', 'parent', 'child']);
});

test('flex items obey z-index without a position', () => {
  const plain = box('root', {}, [box('row', { display: 'flex' }, [box('a'), box('b')])]);
  assert.deepEqual(paintOrder(plain), ['root', 'row', 'a', 'b']);
  const raised = box('root', {}, [box('row', { display: 'flex' }, [box('a', { 'z-index': '1' }), box('b')])]);
  assert.deepEqual(paintOrder(raised), ['root', 'row', 'b', 'a']);
  const block = box('root', {}, [box('col', {}, [box('a', { 'z-index': '1' }), box('b')])]);
  assert.deepEqual(paintOrder(block), ['root', 'col', 'a', 'b']);
});

test('a positioned box with z-index: auto paints its plain children with it', () => {
  const page = box('root', {}, [box('p', { position: 'relative' }, [box('inner')]), box('later')]);
  assert.deepEqual(paintOrder(page), ['root', 'later', 'p', 'inner']);
});

test('sticky headers and will-change: opacity are contexts at level 0', () => {
  const sticky = box('root', {}, [
    box('header', { position: 'sticky', top: '0' }, [box('menu', { position: 'absolute', 'z-index': '9999' })]),
    box('card', { position: 'relative', 'z-index': '1' }),
  ]);
  assert.deepEqual(paintOrder(sticky), ['root', 'header', 'menu', 'card']);
  const hinted = box('root', {}, [
    box('header', { position: 'relative', 'will-change': 'opacity' }, [box('menu', { position: 'absolute', 'z-index': '10' })]),
    box('card', { position: 'relative', 'z-index': '1' }),
  ]);
  assert.deepEqual(paintOrder(hinted), ['root', 'header', 'menu', 'card']);
});
A hint

Write paintContext(context). Walk the context's descendants in tree order and sort each box into one of four lists, without walking into a child context, which is painted later as a whole. A positioned box with z-index: auto goes into the fourth list with a small list of its own for its plain descendants. Then return [context.id], followed by each list in order, calling paintContext again for every context you meet.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 The menu and the card overlap. Which one is on top?

    Read the code, then choose one answer.

    .header { position: relative; z-index: 1; }
    .header .menu { position: absolute; z-index: 9999; }
    .card { position: relative; z-index: 2; } /* comes after .header */
    Show the answer to question 1

    Answer: The card, because the menu's 9999 only counts inside the header's stacking context

    The header has a position and z-index: 1, so it is a stacking context, and everything inside it is painted as one unit at level 1. The card is a context at level 2 in the same parent context, so it is painted after the whole header, menu included. All three engines put the card on top. Tree order only decides between equal levels.

  2. Question 2 of 6 A box with position: absolute; z-index: -1 sits inside a parent with position: relative and a background colour. Where is it painted?

    Choose one answer.

    Show the answer to question 2

    Answer: Behind the parent's background, so it can't be seen

    The parent has no z-index, so it is not a stacking context, and the child's −1 is compared in the context around them: negative levels paint before the boxes in flow and before positioned boxes such as the parent. In the lesson's run the parent was the top box over the child in all three engines. With isolation: isolate on the parent, the child was painted above the parent's background instead.

  3. Question 3 of 6 Which of these make a box a stacking context?

    Choose every answer that is right.

    Show the answer to question 3

    Answer:

    • will-change: opacity
    • position: sticky, even without top
    • opacity: 0.99

    Opacity below 1, sticky and fixed positioning, and a will-change that names a property which would create a context all make one, as Chromium, Firefox and WebKit agreed. A relatively positioned box needs a z-index other than auto, overflow never makes one, and z-index is ignored on a block that is neither positioned nor a flex or grid item.

  4. Question 4 of 6 Which declaration creates a stacking context and does nothing else, with no containing block, no clipping and no change to how the box looks?

    Choose one answer.

    Show the answer to question 4

    Answer: isolation: isolate

    isolation: isolate exists to create a stacking context. A transform also makes the box the containing block of its absolute and fixed descendants, opacity changes how it looks, however slightly, and overflow: hidden clips its content without creating a context at all.

  5. Question 5 of 6 A dropdown must appear over the rest of the page, but its header has overflow: hidden, a transform and z-index: 1, and you may not change the header. What works?

    Choose one answer.

    Show the answer to question 5

    Answer: Make the dropdown a popover, so the browser shows it in the top layer

    No z-index on the dropdown can lift it out of the header's context, and the header clips it anyway. A fixed dropdown is captured by the header's transform too. The top layer sits above every stacking context and is not clipped by any ancestor, so the popover was on top in all three engines.

  6. Question 6 of 6 In the explorer, the header gets isolation: isolate and z-index: 2, and the card keeps z-index: 1. Which is on top where the menu and the card overlap?

    Choose one answer.

    Show the answer to question 6

    Answer: The menu, because the header's context now sits above the card

    Isolation still keeps the menu inside the header's context, but that context is now at level 2, above the card's level 1, so the whole header, menu included, is painted after the card. That is the usual fix: raise the context that holds the menu, not the menu itself.

Interview questions

Fresher · What is a stacking context, and why can a z-index of 9999 still lose?

A stacking context is a group of boxes that the browser paints as one unit, at one level of its parent’s stack. z-index orders the boxes inside a context, but the group as a whole is compared with its neighbours only by its own level. The root element is one, and so is any positioned box with a z-index other than auto, any fixed or sticky box, and any box with opacity below 1, a transform, a filter or isolation: isolate. A menu with z-index: 9999 inside a header that is a context at level 1 is painted as part of the header, so a card at level 2 beside it covers the menu; Chromium, Firefox and WebKit all agreed in a measured case. To fix it, raise the header’s context, remove a context the header doesn’t need, or show the menu as a popover in the top layer.

Key takeaways

  • z-index orders boxes only inside their stacking context; a child can’t be painted above something its context is below.
  • Inside a context: the background, negative levels, plain boxes, floats, inline content, positioned boxes and contexts at level 0, then positive levels.
  • Positioned boxes with a z-index, fixed and sticky boxes, opacity below 1, transforms, filters, isolation and contain: paint create contexts; position: relative alone, overflow, opacity: 1 and container-type don’t.
  • z-index works on positioned boxes and on flex and grid items, and a negative value goes behind the parent unless the parent is a context.
  • Use isolation: isolate on component roots, and the top layer, through popover and showModal(), for anything that must cover the whole page.
  • Debug by comparing the two boxes’ stacking contexts where their paths meet, and keep a small named scale instead of ever larger numbers.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.