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.
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
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.
- The stacking context's own background and border.
- Child stacking contexts with a negative z-index, the most negative first.
- Boxes in normal flow that are not positioned: their backgrounds and borders, in tree order.
- Floats that are not positioned.
- Inline content: text, images and inline-blocks.
- Positioned boxes with z-index auto or 0, and stacking contexts made by properties such as opacity and transform, in tree order.
- 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:
<!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:
<!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:
<!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: relativeorabsolutewith az-indexother thanauto;position: fixedandposition: sticky, always, even with no inset;- a flex or grid item with a
z-indexother thanauto, with or without a position; opacitybelow 1, and amix-blend-modeother thannormal;transform,translate,rotate,scale,filter,backdrop-filter,perspective,clip-pathormask-imagewith any value other thannone;isolation: isolate;contain: layout,paint,strictorcontent, andcontent-visibility: auto;- a
view-transition-nameother thannone, and awill-changethat 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 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
<!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 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
- 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. - 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. - 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.
- 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 ownz-indexnever helps once a lower context holds it.
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: fixedorsticky; apositionother thanstaticwith az-indexother thanauto; az-indexother thanautoas an item of a parent whosedisplayisflex,inline-flex,gridorinline-grid;opacitybelow 1; any oftransform,translate,rotate,scale,filter,backdrop-filter,perspective,clip-pathormask-imageother thannone;mix-blend-modeother thannormal;isolation: isolate;containwithlayout,paint,strictorcontent;content-visibility: auto; or awill-changenaming one of those properties. Thecontexts.mjsexample of the lesson is a good start. - Levels. A context's level is its
z-indexas 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: autoand 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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-indexorders 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,isolationandcontain: paintcreate contexts;position: relativealone,overflow,opacity: 1andcontainer-typedon’t. z-indexworks 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: isolateon component roots, and the top layer, throughpopoverandshowModal(), 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
- CSS 2: Appendix E, the painting order of stacking contexts (W3C CSS Working Group)
- CSS 2: layered presentation and the z-index property (W3C CSS Working Group)
- CSS Positioned Layout Module Level 3: painting order and stacking contexts (W3C CSS Working Group)
- CSS Positioned Layout Module Level 4: the top layer (W3C CSS Working Group)
- CSS Color Module Level 4: transparency and the opacity property (W3C CSS Working Group)
- CSS Transforms Module Level 1: transform rendering model (W3C CSS Working Group)
- Compositing and Blending Level 1: the isolation property (W3C CSS Working Group)
- CSS Flexible Box Layout Module Level 1: painting flex items (W3C CSS Working Group)
- HTML Standard: the popover attribute (WHATWG)
- HTML Standard: the dialog element's showModal() method (WHATWG)
- CSSOM View Module: elementFromPoint() (W3C CSS Working Group)
- Stacking context (MDN Web Docs) (Mozilla)
- z-index (MDN Web Docs) (Mozilla)
- Popover (web-features) (W3C WebDX Community Group)
- isolation (web-features) (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress