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 exceptnone(e.g.,transform: translateX(0))perspective- Any value exceptnonefilter- Any value exceptnone(e.g.,filter: blur(0))will-change- When set totransform,perspective, orfiltercontain- Values oflayout,paint, or a combination including eitherbackdrop-filter- Any value exceptnone
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:
- Apply transformations/filters in a specific rendering order
- Create a new coordinate system for the transformed/filtered content
- 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
- Be intentional: Only add
transformor similar properties when you need the containing block behavior - Document it: Comment your CSS when intentionally creating a new containing block
- 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: stickyif 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