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.
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 az-indexother thanauto position: fixedorposition: sticky, whatever itsz-indexis- a flex or grid child with a
z-indexother thanauto, even withoutposition transform,filter,perspectiveorbackdrop-filterset to anything butnoneopacitybelow 1,isolation: isolate,contain: paint, andwill-changenaming 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-indexat all? Without one it paints in document order at theautolevel, 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 byanimation-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-indexthat is only meant to win locally belongs inside an element withisolation: 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.