Concept
position controls two things at once: whether an element participates in normal document flow, and what its top/right/bottom/left offsets are calculated relative to. Every positioning bug traces back to getting one of those two questions wrong.
The five values
position: static; /* default, normal flow, offsets ignored entirely */
position: relative; /* normal flow, offset relative to its OWN normal position */
position: absolute; /* removed from flow, offset relative to nearest positioned ancestor */
position: fixed; /* removed from flow, offset relative to the viewport (ignores scroll) */
position: sticky; /* normal flow until a scroll threshold, then acts fixed within its container */relative, offsets from itself, still takes up space
.box { position: relative; top: 10px; left: 20px; }The element still occupies its original space in the document flow (siblings lay out as if it weren't moved), only its painted position shifts. This is also relative's other critical job, independent of whether you use top/left at all: it establishes a positioning context for absolutely-positioned children.
absolute, the "relative to nearest positioned ancestor" rule
.parent { position: relative; } /* establishes the positioning context */
.child { position: absolute; top: 0; right: 0; } /* positioned relative to .parent, not the page */An absolutely positioned element is removed from normal flow entirely (siblings act as if it doesn't exist) and its offsets are calculated relative to the nearest ancestor with a position other than static, if no ancestor has one, it falls all the way back to the initial containing block (roughly, the viewport/<html>). This is the single most common positioning bug: forgetting to set position: relative on the intended container, causing an absolutely positioned child to jump to a completely unexpected position relative to the page instead of the nearby parent.
fixed, relative to the viewport, with one major caveat
.modal-backdrop { position: fixed; inset: 0; } /* covers the full viewport, ignores scroll */fixed positions relative to the viewport and doesn't move on scroll, except when an ancestor has a transform, filter, perspective, or will-change property set, in which case that ancestor becomes the containing block instead of the viewport, and the "fixed" element scrolls along with it. This is a genuinely surprising, very commonly hit real bug: a modal or tooltip using position: fixed suddenly starts scrolling with the page because someone added transform to an ancestor for an unrelated animation.
sticky, the hybrid
.header { position: sticky; top: 0; } /* normal flow, then "sticks" at top:0 once scrolled to */sticky behaves like relative until the element would scroll past its specified offset threshold, at which point it behaves like fixed, but only within the bounds of its containing block. Once the parent container scrolls out of view, the sticky element scrolls away with it rather than staying pinned forever. Requires an explicit offset (top, bottom, etc.) to actually activate, without one, sticky behaves exactly like static.
Common sticky gotcha: if any ancestor has overflow: hidden, overflow: scroll, or overflow: auto (other than the intended scrolling container), it can silently break sticky positioning, since sticky is computed relative to the nearest scrolling ancestor, and an unrelated overflow value elsewhere in the ancestor chain can become that scrolling context unexpectedly.
z-index and stacking contexts
.a { position: relative; z-index: 1; }
.b { position: relative; z-index: 2; } /* renders above .a */z-index only has an effect on positioned elements (position other than static), or flex/grid items, which have grown to also respect z-index even without an explicit position. Critically, z-index values only compare within the same stacking context, a z-index: 9999 element trapped inside a stacking context with a low z-index is still visually behind an element with z-index: 2 in a different, higher stacking context, regardless of the raw numbers. This is the source of the infamous "why won't my z-index: 99999 show on top" bug.
What creates a new stacking context (a non-exhaustive but common list): position + z-index other than auto, opacity less than 1, transform, filter, will-change, isolation: isolate. Any of these on an ancestor creates a self-contained stacking bubble, descendants' z-index values are only meaningful relative to siblings within that same bubble, not the whole page.
Common Mistakes
1. Forgetting position: relative on the intended positioning container
/* Wrong: .badge jumps relative to the page/viewport, not .card */
.card { /* no position set */ }
.badge { position: absolute; top: 0; right: 0; }/* Right */
.card { position: relative; }
.badge { position: absolute; top: 0; right: 0; }The most common positioning bug by a wide margin, always double-check the intended containing element actually has a non-static position.
2. Chasing a z-index bug by raising the number instead of finding the stacking context boundary
.tooltip { z-index: 999999; } /* still invisible, wrong problem being solved */If a higher z-index doesn't fix a stacking issue, the actual problem is almost always that the element is trapped inside a lower stacking context created by an ancestor (an opacity, transform, or positioned+z-index ancestor), no child z-index value can escape its own stacking context. Trace the ancestor chain for stacking-context-creating properties instead of increasing the number further.
3. position: fixed unexpectedly scrolling because of an ancestor transform
Covered above, if a "fixed" element starts moving with scroll, check every ancestor for transform/filter/will-change/perspective, since any of them silently redefines the fixed positioning containing block.
4. position: sticky not sticking, due to an ancestor's overflow
If sticky isn't activating, check the entire ancestor chain (not just the immediate parent) for any overflow value other than visible, a distant ancestor's overflow: hidden set for a completely unrelated reason can silently disable sticky behavior.
5. Using absolute positioning for layout that flexbox/grid would handle more robustly
/* Fragile: manually computed positions, breaks on content size changes */
.item-1 { position: absolute; top: 0; left: 0; }
.item-2 { position: absolute; top: 0; left: 100px; }Absolute positioning removes elements from flow entirely, appropriate for overlays, tooltips, badges, modals (things genuinely meant to float independent of document flow), but a poor substitute for actual multi-item layout, which Flexbox/Grid handle far more robustly (responsive by nature, no manual pixel math, no overlap when content sizes change).
Best Practices
- Always pair
position: absolutechildren with an explicitposition: relative(or other non-static) parent, set deliberately, not accidentally inherited from elsewhere. - Use
inset: 0as a shorthand fortop: 0; right: 0; bottom: 0; left: 0;when covering a positioned parent entirely (overlays, backdrops). - When debugging z-index, trace stacking contexts, not just raise numbers, inspect each ancestor in DevTools for
position+z-index,opacity < 1,transform,filter. - (tooltips, modals, badges, sticky headers), use Flexbox/Grid for actual multi-item layout.
Further Resources
- MDN,
position - MDN, Stacking context
- CSS-Tricks, What No One Told You About Z-Index
- Josh W. Comeau, An Interactive Guide to CSS Position
- web.dev, Learn CSS: Position
