logo

CSS position fixed

fixed position

For a long time I believed that an element with position: fixed would always be positioned relative to the viewport. However, this is not always the case, as outlined in the MDN documentation:

It is positioned relative to the initial containing block established by the viewport, except when one of its ancestors has a transform, perspective, or filter property set to something other than none

Simply put, if any ancestor has certain CSS properties set, that ancestor becomes the containing block instead of the viewport.

Properties that create a new containing block

The following properties, when set to values other than their defaults, will create a new containing block for fixed-positioned descendants:

  • transform - Any value except none (e.g., transform: translateX(0))
  • perspective - Any value except none
  • filter - Any value except none (e.g., filter: blur(0))
  • will-change - When set to transform, perspective, or filter
  • contain - Values of layout, paint, or a combination including either
  • backdrop-filter - Any value except none
I am fixed to wrapper container
import React, { useState } from 'react'

export default function App() {
  const [isRelativeToViewport, setIsRelativeToViewport] = useState(false)

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-2">
        <label className="flex cursor-pointer items-center gap-2">
          <input
            type="checkbox"
            checked={isRelativeToViewport}
            onChange={(e) => setIsRelativeToViewport(e.target.checked)}
            className="h-4 w-4"
          />
          <span>Relative to viewport</span>
        </label>
      </div>

      <div
        className="wrapper relative h-40 w-full overflow-auto border border-gray-300 p-4 dark:border-gray-600"
        style={{ transform: isRelativeToViewport ? 'none' : 'translateX(0)' }}
      >
        <div className={`fixed top-2 left-2`}>
          I am fixed {isRelativeToViewport ? 'to viewport' : 'to wrapper container'}
        </div>
      </div>
    </div>
  )
}

Try it yourself: Scroll the page and observe the fixed box. With the checkbox enabled, it's positioned relative to the wrapper box (with transform: translateX(0)) rather than the viewport. Toggle the checkbox to see the difference.

Why this happens: The CSS specification

This behavior exists because these properties create a new stacking context and force the browser to establish a new containing block. The browser needs to:

  1. Apply transformations/filters in a specific rendering order
  2. Create a new coordinate system for the transformed/filtered content
  3. Maintain proper layering and paint order

When an element creates a stacking context, fixed-positioned descendants are rendered within that context, making them relative to the transformed ancestor instead of the viewport.

Practical use cases

1. Containing third-party components

When using a third-party plugin/component with position: fixed but you want it positioned relative to your container rather than the viewport. If you can't modify the source code, wrap it with a container that has transform: translateX(0):

.wrapper {
  transform: translateX(0); /* Creates new containing block */
}

2. Modal dialogs within containers

Create modals that are fixed within a specific section of your page rather than the entire viewport:

.modal-container {
  transform: translate3d(0, 0, 0);
  overflow: auto;
}

.modal {
  position: fixed;
  /* Now fixed relative to .modal-container */
}

Common pitfalls and debugging

Unexpected positioning

If your fixed element isn't staying at the viewport edge, check all ancestors for:

/* Any of these will break viewport-relative fixed positioning */
.ancestor {
  transform: scale(1);
  filter: brightness(1);
  will-change: transform;
  contain: paint;
}

Debugging tip: Use browser DevTools to inspect computed styles and search for these properties up the DOM tree.

Best practices

  1. Be intentional: Only add transform or similar properties when you need the containing block behavior
  2. Document it: Comment your CSS when intentionally creating a new containing block
  3. Test thoroughly: Check fixed positioning behavior at different scroll positions and viewport sizes

Alternatives

If you need viewport-relative positioning despite transformed ancestors:

  • Use position: sticky if appropriate for your use case
  • Restructure your DOM to avoid transformed ancestors
  • Use portal-based solutions (React Portals, Vue Teleport) to render outside the transformed tree