·11 min read

CSS z-index not working with position: fixed

A popup under other elements, a label under its neighbours, zoom buttons over a popup. The z-index values were right every time. I broke the layering four ways and 1,404 tests stayed green.

csswebtestinggamedev

Contents

The first thing I complained about in a game-builder app I am working on was popups that render under other elements. It got fixed. The next day I wanted to know what would stop it coming back, so I listed every way a popup can end up underneath something, broke the stylesheet each way in turn, and ran the tests after every break.

The suite had 1,404 tests. All of them passed every time, including on the one-line change that reproduced my original complaint exactly.

Then the same kind of bug turned up twice more, in two of my games, with a different cause each time. In none of the three was the z-index value wrong.

Five ways to put a popup underneath

The builder draws its canvas as three stacked layers: the scene with the pieces in it, an adornment layer for handles and guides, and an overlay for menus and popups. The stylesheet is about 2,600 lines and holds only eight z-index declarations. Two matter here. The builder snippets in this post are minimal reconstructions with the class names changed, not its code:

.overlay { position: absolute; z-index: 10; }  /* popups on the canvas */
.menu    { position: absolute; z-index: 20; }  /* the menu bar's dropdown */

A grep for anything that starts a stacking context of its own — transform, opacity, filter, will-change, isolation, contain, backdrop-filter, perspective — was the other half of the list. Then I broke things, with a byte copy of the stylesheet to restore from after each run:

Change to the stylesheet Builder tests
delete the overlay's z-index 1,404 passed
overlay at z-index: -1, behind the scene 1,404 passed
menu dropdown at z-index: -1 1,404 passed
toolbar given position: relative; z-index: 0 1,404 passed

The second row is my original complaint, back in one line. Nothing in the repository read a z-index. The test that guarded the layers checked the DOM: one overlay root, never nested inside the scene, the layers in order. That was half of the contract. The other half lived in the stylesheet, and no test opened it.

The fourth row is the one I would not have thought to try without the list, because it changes no z-index on either element involved.

How z-index and position: fixed work together in CSS

z-index does not compare two elements across the page. It orders siblings inside one stacking context, and the root element is only the outermost of those. When an element starts a context of its own, everything inside it is painted as a unit, at that element's level, and no z-index inside can climb out of it.

What starts one, per the spec:

  • a positioned element (relative, absolute) with a z-index other than auto
  • position: fixed or position: sticky, whatever its z-index is
  • a flex or grid child with a z-index other than auto, even without position
  • transform, filter, perspective or backdrop-filter set to anything but none
  • opacity below 1, isolation: isolate, contain: paint, and will-change naming any of these

Inside one context, painting runs in this order: negative z-index, then ordinary content, then positioned elements whose z-index is auto or 0 in document order, then positive z-index from low to high.

That order is most of the answer to the usual CSS fixed z-index not working question, where a position: fixed element sits underneath something. fixed takes the element out of the flow, but it does not give it a z-index. With none, it paints at the auto level in document order, so anything in the same context with a z-index of 1 sits above it, however early that thing appears in the page.

The builder's menu bar shows the other half. Here is the chain from the stylesheet:

.app                   grid, static              → no context
├── .toolbar           static                    → no context
│   └── .menu-anchor   relative, z-index auto    → no context
│       └── .menu      absolute, z-index 20
└── .stage             relative, z-index auto    → no context
    └── .overlay       absolute, z-index 10

position: relative with z-index: auto starts nothing, so the menu and the overlay are compared in the same context, and 20 beats 10. The dropdown covers the canvas, as it should.

It is right by accident. Give the toolbar position: relative; z-index: 0, the most ordinary thing a restyle does to fix some unrelated overlap, and the toolbar becomes a context. The menu's 20 is now only compared with the toolbar's other children. Against the stage, it is the toolbar's 0 that counts, and 0 loses to 10. By the spec, the overlay paints over the open menu. The toolbar is a grid child, so the z-index: 0 alone would have done it.

I did not look at that one on screen. The tests run in happy-dom, which does no layout and no painting, so the measurement is the green suite, not the overlap. The overlap rests on the spec.

The fifth one I got wrong

The scene and adornment layers have no z-index at all. Both are position: absolute with z-index: auto, so they paint in document order and nothing else decides it. I wrote down "swap the two JSX blocks" as a fifth mechanism, the cheapest of them, because it needs no CSS change, and filed it as unguarded too.

I never ran it. A test written a day earlier already compares the layers' DOM order with the constant that defines them, so the swap would have failed that test. I found that later by reading it, which is also how I had got it wrong: I reasoned about it and didn't break anything. Four measured, one assumed, and the assumed one was the mistake.

The guard

happy-dom does no layout, so the guard reads the stylesheet text. It asserts that the overlay's z-index is above zero, that the scene and adornment have none, that the menu's is higher than the overlay's, and that four ancestors of the two carry no z-index:

for (const ancestor of [".app", ".toolbar", ".menu-anchor", ".stage"]) {
  expect(zIndexOf(ancestor), ancestor).toBeUndefined();
}

Before merging it, I sent the overlay behind the scene again:

Tests  1 failed | 1 passed (2)

That is the only perturbation I ran against the guard. Reading it, the ancestor loop covers the fourth row of the table. It does not cover a transform, filter, opacity or isolation added to the same ancestors, any of which starts a context just as well. It only reads z-index.

A transform that was never removed

In ASMR Logo Craft Block, a logo's name label shows on its tile when you touch it, and it sometimes slid under the neighbouring tiles. Inside its piece, the named tile was already lifted with z-index: 15. It made no difference, and the reason was the entrance animation:

@keyframes candyTileIn {
  0%   { opacity: 0; transform: scale(0.4) rotate(-6deg); }
  65%  { opacity: 1; transform: scale(1.08) rotate(1deg); }
  100% { opacity: 1; transform: scale(1) rotate(0); }
}
.candy-tile-in { animation: candyTileIn 0.42s cubic-bezier(0.3, 1.3, 0.5, 1) both; }

both keeps the last keyframe applied after the animation ends. scale(1) rotate(0) draws nothing different from no transform, but it is not none, so every board piece stayed its own stacking context for good. The label's 15 was compared only with things inside its own piece, and any piece later in the document painted over it. "Sometimes" was just which neighbour came later in the document.

That is the CSS transform z-index not working case, and the fix is to raise the piece rather than the thing inside it:

.board-piece[data-naming] { z-index: 2; }

That fix was not checked on a device when it went in. The 1,910 tests passed, and none of them read which element is on top.

The next bug was in the same rule. When a name hid, its 450 ms fade began and the lift came off in the same instant, so the fading label dropped behind its neighbours for most of the fade. On the tiles the lift now stays on for the 450 ms in JavaScript; a CSS-only version did not work there and I did not chase why. On the banner it is CSS, with the same delayed-transition trick I used for visibility in an earlier post:

.banner-pictures              { z-index: 2; transition: z-index 0s linear 0.45s; }
.banner-pictures[data-naming] { z-index: 4; transition: none; }

Raising is instant; dropping waits for the fade. A check in WebKit halfway through a fade found the tile still lifted and the label on top. The clue badge had the same problem and the same fix, and I confirmed that one on an iPhone.

z-index not working with position: fixed and position: absolute divs

Picture Word Connect is getting a zoomable board, and its zoom buttons drew over the settings popup and over an enlarged picture. The buttons were position: absolute with zIndex: 1. The enlarged picture's shade was position: fixed with no z-index at all, and it relied on coming later in the page to be on top. Per the painting order above, a later element at the auto level loses to an earlier one at 1.

I could have given the popups a bigger number. That moves the problem: the next popup without one has the bug again, and the numbers become a race. The buttons' zIndex: 1 was there to lift them over the board's canvas and nothing else, so I gave the board a context of its own:

surface: { position: 'relative', flex: 1, width: '100%', minHeight: 0, isolation: 'isolate' },

isolation: isolate starts a stacking context and does nothing else. The buttons still sit over the canvas, and the board as a whole paints at the auto level, in document order, under anything that comes after it. In the browser, with the settings popup or an enlarged picture open, the buttons now draw underneath where before they drew on top. The game's 485 tests passed.

Our shared pan/zoom package had the same unbounded zIndex: 1 on its own floating buttons. It got the same one-line fix in a patch release, checked in Chrome and WebKit: a plain overlay with no z-index, added after the canvas, now covers its buttons.

What isolation costs

An isolated element also keeps its blend modes inside it. In Picture Word Connect that cost nothing, because every glow on the board blends normal. In ASMR Logo Craft Block it cost something. Each screen there was already isolated, so a home button's z-index cannot rise above a modal drawn over the screen. Later I gave a light burst a blend mode meant to brighten the sky behind it, and walking the burst's ancestors through the WebView's remote debugger showed the screen's isolation stopping it, along with one more rule. I kept both. A brightening blend only lightens, so over a pale sky the glow would have faded to nothing anyway.

Why z-index is not working with position: fixed: a checklist

Most searches for this are some form of CSS z-index not working, position: fixed or absolute, and from these three bugs the number is rarely what is losing. None of it is specific to React or to the builder; it is the same HTML CSS z-index not working problem with or without a framework. In the order I would now check:

  • CSS position: fixed z-index not working: does the fixed element have a z-index at all? Without one it paints in document order at the auto level, under anything in the same context with a positive value.
  • CSS transform z-index not working: does an ancestor have a transform, even an identity one left behind by animation-fill-mode: both? That ancestor is the thing to raise.
  • CSS absolute z-index not working, with absolute position on both elements: walk up from each and find the first ancestor that starts a context. If they are different ancestors, compare those two, not your two.
  • Anything with a z-index that is only meant to win locally belongs inside an element with isolation: isolate, so it cannot win anywhere else.

Takeaway

A test suite that never opens the stylesheet will not catch any of this, and mine didn't: 1,404 green on a change that put the popups back underneath. Write the layering down as something a test can read, then break it in each of the ways you listed and watch the test go red. The mechanisms that change no z-index value are the ones a guard comparing numbers will pass.

More posts

New writing when there is something worth saying. By email or by RSS, whichever you prefer.