Skip to content
Preptima
mediumConceptMidSenior

A fixed header starts scrolling away with the page after someone adds an animation to a wrapper div. What happened, and how would you track it down?

A transform, filter, perspective or containment on an ancestor makes that ancestor the containing block for fixed-position descendants, so the header is now positioned and clipped relative to the wrapper instead of the viewport.

4 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Whether the candidate reaches for the containing block rather than blaming z-index or overflow
  • Does the answer name the property groups that turn an ancestor into a containing block for fixed descendants
  • That the diagnosis is walking the ancestor chain's computed styles rather than guessing at the CSS
  • Whether a structural fix is offered instead of only deleting the offending declaration
  • Does the candidate carry the same reasoning across to sticky positioning failing inside a scroll container

Answer

Fixed is only fixed to the viewport by default

Every positioned element resolves its offsets against a containing block, and the whole of this bug is that position: fixed does not always get the one you assume. Ordinarily the containing block for a fixed element is the viewport, which is what makes it stay put while the document scrolls underneath it. That behaviour is conditional. If any ancestor in the chain has been given certain properties, that ancestor becomes the containing block instead, and a fixed element inside it is positioned relative to the ancestor's box, scrolls with it, and is clipped by it. Nothing about the header's own CSS changed, which is why the diff that broke it looks unrelated.

The properties that do this are the ones that force the browser to establish a new coordinate space or to promise that the element's contents cannot affect anything outside it. A transform with any value other than none does it. So does perspective, filter, backdrop-filter, and will-change naming any of those. Containment does it as well: contain: layout, contain: paint and the shorthands that include them, and by extension an element declared as a container query container, since container-type applies containment in order to work at all.

That list explains why this arrives from directions nobody is watching. An animation library adds transform: translateY(0) to a wrapper for the duration of a transition, so the header detaches only while the animation runs. Somebody applies will-change: transform to a scroll container hoping for smoother scrolling. A design system adds contain: paint to a card to limit repaint cost. A container query is introduced on a layout region months after the header was written. Each of those is a reasonable change in isolation, and each one silently re-parents every fixed descendant.

Reading it off the ancestor chain

Do not go looking through the stylesheets. The fastest diagnosis is to start at the misbehaving element and walk upwards asking one question per ancestor: does its computed style contain any of those properties. Computed style is what matters, because the value may come from a rule you never read, an inline style set by JavaScript, or a state class present only mid-animation.

// Walk up from the broken element and print any ancestor that has
// taken over as containing block for fixed descendants.
for (let el = document.querySelector('.site-header'); el; el = el.parentElement) {
  const s = getComputedStyle(el);
  if (s.transform !== 'none' || s.perspective !== 'none' ||
      s.filter !== 'none' || s.contain.match(/layout|paint|strict|content/)) {
    console.log(el, s.transform, s.filter, s.contain);
  }
}

Running that while the animation is in flight is the point, because a transform applied for two hundred milliseconds will not be there when you inspect the element afterwards. The same walk tells you which ancestor is doing the clipping, and it usually explains a second symptom the reporter mentioned in passing, such as the header's box-shadow being cut off along one edge or a dropdown inside it being truncated.

Two fixes, and only one of them survives contact

The tempting fix is to remove the transform, and sometimes that is right: translateZ(0) and speculative will-change declarations added for imagined performance benefit should go regardless. But if the transform is doing real work, removing it is not available to you, and the deeper problem is that the header's correctness depends on no ancestor ever acquiring one of half a dozen properties. That is an invariant nobody can maintain across a large codebase.

The structural fix is to stop nesting the element inside the layout at all. Render it as a child of the body, through a portal in a component framework, so its ancestor chain is trivially short and contains nothing that could take over. For genuinely modal content, the browser now has a purpose-built answer: an element promoted to the top layer, whether by a dialog opened modally or through the popover mechanism, is painted outside the normal ancestor hierarchy and therefore cannot be re-parented or clipped by a transform somewhere above it. That also disposes of the sibling problem of trying to out-bid the rest of the page on z-index.

The same mistake wearing different clothes

Recognising the general shape is worth more than memorising the property list, because the containing-block rule turns up repeatedly. A percentage width or a top offset on the fixed element now resolves against the wrapper's box rather than the viewport, so the element can change size as well as position, which sends people hunting for a sizing bug that does not exist.

position: sticky has a related failure with a different mechanism, and being able to keep them apart is a good signal. A sticky element sticks within its nearest scrolling ancestor, so the two common reasons it does nothing are that some ancestor has overflow set to hidden, auto or scroll and thereby became the scroll container that never actually scrolls, or that the element's own parent is no taller than the element itself, leaving no distance over which to stick. Neither is about transforms, but the diagnostic habit is identical: the element's behaviour is defined by its ancestors, so read the ancestors.

position: fixed is fixed relative to whatever the browser has decided is its containing block, and half a dozen unrelated-looking properties on any ancestor can take that role. If an element must be positioned against the viewport, get it out of the layout tree rather than hoping nobody upstream adds a transform.

Likely follow-ups

  • Where exactly does the header get clipped now, and which declaration on the wrapper decides that?
  • How does a dialog opened with showModal escape this problem, and what do you give up in return?
  • A percentage width on the fixed element resolves to a different number after the change. Why?
  • A sticky sidebar never sticks anywhere. Which two ancestors do you suspect, and how do you tell them apart?

Related questions

Further reading

csspositioningcontaining-blockstackingtransforms