Skip to content
Ramesh Gautam

Why my fixed mobile menu was 81px tall: backdrop-filter and containing blocks

Why my fixed mobile menu was 81px tall: backdrop-filter and containing blocks, a Frontend article by Ramesh Gautam

The mobile menu on this site looked fine in my head and broken on a phone. Tapping the menu button opened a strip 81px tall that showed one link, “About”, floating over the page. Everything else was cut off.

The panel’s CSS looked correct:

.header__panel {
	position: fixed;
	inset: 68px 0 0 0; /* below the header, to the bottom of the screen */
	overflow-y: auto;
	background: var(--color-background);
}

position: fixed with inset should size the panel against the viewport. It was sizing itself against the 68px header instead.

The cause: a new containing block

A fixed-position element is normally positioned relative to the viewport. But some CSS properties on an ancestor turn that ancestor into the containing block for fixed descendants, and then “fixed” means fixed to that element. The properties that do this include:

  • transform (any value other than none)
  • filter and backdrop-filter
  • perspective
  • contain: paint or layout
  • will-change with any of the above

The header had a frosted-glass effect:

.header {
	position: sticky;
	top: 0;
	background: rgb(245 247 246 / 0.88);
	backdrop-filter: saturate(1.4) blur(12px);
}

That backdrop-filter made the header the containing block. The panel’s inset: 68px 0 0 0 was measured inside a 68px-tall box, so its height came out as zero, plus padding. Hence 81px.

It’s easy to miss because it only shows up when the fixed element is a descendant. Desktop navigation wasn’t affected because it isn’t fixed.

The fix: move the effect to a pseudo-element

The blur has to stay on the header visually, but not on the element that contains the panel. A pseudo-element behind the header’s content does that:

.header {
	position: sticky;
	top: 0;
	z-index: 100;
}

.header::before {
	content: "";
	position: absolute;
	inset: 0;
	z-index: -1;
	background: rgb(245 247 246 / 0.88);
	backdrop-filter: saturate(1.4) blur(12px);
}

The header looks identical. Because the backdrop-filter is now on a sibling layer rather than an ancestor of the panel, the panel’s containing block is the viewport again, and inset works as intended.

z-index: -1 keeps the layer behind the header’s content. It can’t fall behind the page, because the header’s own z-index creates a stacking context.

Two small extras

While the menu is open, the header and panel should read as one surface. I make the header background solid in that state:

.header.is-open::before {
	background: var(--color-background);
}

And I test the open menu at real phone and tablet widths, in both light and dark mode, with a script that measures the panel’s height. If it’s ever smaller than the screen below the header, the test fails. A one-line check would have caught this before it shipped.

When you see it again

If a fixed or position: sticky element behaves as if it’s trapped in a box, walk up its ancestors in DevTools and look for transform, filter, backdrop-filter, perspective, contain or will-change. It’s the same family of problem I cover in debugging cross-browser layout bugs: reduce the case, find the property, fix the cause.