07 · Positioning & Stacking Contexts¶
Normal flow, Flexbox and Grid place boxes relative to each other. The position
property is for the exceptions: a badge in the corner of a card, a header that sticks
while you scroll, a dropdown that floats above the content, a tooltip. It's also the
source of the most frustrating CSS bug there is — the z-index that doesn't work no
matter how big you make it. That bug has a precise explanation, and it's in this lesson.
The five values¶
position |
In normal flow? | Offsets (top/left/…) are relative to |
Scrolls with page? |
|---|---|---|---|
static (default) |
yes | offsets ignored | yes |
relative |
yes (space kept) | its own normal position | yes |
absolute |
no (space collapses) | the containing block: nearest positioned ancestor | yes |
fixed |
no | the viewport (usually) | no |
sticky |
yes | its scroll container, once a threshold is crossed | until its parent scrolls away |
inset: 0 is shorthand for top: 0; right: 0; bottom: 0; left: 0.
relative¶
Moves the element visually without affecting anything around it — the space it would
have taken is kept. On its own, that's rarely useful. Its main job is to be an anchor:
it makes the element a containing block for absolutely positioned descendants, and it
lets z-index apply.
absolute and the containing block¶
An absolutely positioned element is removed from the flow and positioned against its
containing block — the padding box of its nearest ancestor with position other than
static (or with a transform, filter or contain set). If there's none, it's the
initial containing block: the viewport-sized box at the top of the page.
We put a 20×20 badge with position: absolute; top: 0; right: 0; inside a card, twice —
once with the card left static, once with position: relative on the card — and
measured where each badge landed in a 1280px-wide page (the second card's box starts at
x=70, y=260 and is 400px wide):
no positioned ancestor: badge at (1260, 0) <- top-right corner of the page
card has position: relative: badge at (450, 260) <- top-right corner of the card
That's the whole pattern:
Absolute elements shrink to fit their content unless you give them a width, or set both
left and right (which stretches them between the two).
fixed¶
Positioned against the viewport, so it stays put while the page scrolls — cookie banners, floating action buttons, full-screen overlays. Two cautions:
-
Fixed elements cover content. A fixed header needs matching
padding-topon the page, and in-page anchor links needscroll-margin-topon their targets so headings don't land underneath it: -
If any ancestor has a
transform,filter,perspective,contain: paintorbackdrop-filter, that ancestor becomes the containing block and "fixed" stops being fixed to the viewport. A modal inside an animated container is the usual victim.
sticky¶
A hybrid: the element stays in normal flow until the scroll position would move it past
its threshold (top: 0), then it sticks — but only within its parent. When the
parent scrolls away, the sticky element goes with it.
We put a sticky 50px header at the top of a 2000px-tall section in a 600px-tall window and measured its top edge after scrolling:
scrolled 1000px: header top = 0 <- stuck to the top of the viewport
scrolled 2500px: header top = -550 <- its section has scrolled past, header went with it
Sticky's two classic failures:
- It needs room to move. If the sticky element's parent is only as tall as the
element itself (for example, you made the
<header>wrapper sticky instead of the whole page's first child), there's nowhere to stick. - An ancestor with
overflowother thanvisiblebecomes its scroll container. We put the same header inside adivwithoverflow: hiddenand scrolled the page 500px: the header's top was −500 — it scrolled away as if it weren't sticky, because it was sticking to thediv(which never scrolls), not to the page. Useoverflow: clipif you only need clipping; it doesn't create a scroll container.
z-index and stacking¶
When positioned elements overlap, z-index decides which paints on top — higher in
front. It applies to positioned elements (and to flex and grid items, even unpositioned).
That description is incomplete in a way that causes real bugs. Here's a test page with a
site header (z-index: 10) and a modal (z-index: 9999) that should cover it:
.header { position: relative; z-index: 10; height: 300px; }
.modal-wrap { position: relative; opacity: 0.99; }
.modal { position: fixed; inset: 100px; z-index: 9999; background: #fff; }
We asked Chromium what's on top at a point where both overlap
(document.elementFromPoint(200, 150)):
z-index: 9999 lost to z-index: 10, and removing an unrelated-looking opacity
fixed it.
How It Actually Works¶
Painting order isn't one global list sorted by z-index. It's a tree of stacking
contexts. A stacking context is a group of elements that are painted together, as a
unit, and whose z-index values only compete with each other.
The root element forms the first stacking context. A new one is created by, among others:
position: relative | absolutewith az-indexother thanautoposition: fixed | sticky(always)opacityless than 1transform,filter,perspective,clip-path,mask,backdrop-filterisolation: isolatemix-blend-modeother thannormalwill-changenaming any of the abovecontain: layoutorcontain: paint- flex and grid items with a
z-indexother thanauto
Inside the test page, opacity: 0.99 made .modal-wrap a stacking context with an
effective z-index of auto (treated as 0 for ordering). The modal's 9999 now only
ranks it inside .modal-wrap. At the root level, the comparison is .header (10)
against .modal-wrap (0) — and the header wins, taking everything inside .modal-wrap
down with it. No z-index on the modal can ever escape its parent's context.
Within a single stacking context, the paint order is:
- The context's own background and borders
- Descendants with negative
z-index - In-flow, non-positioned blocks
- Floats
- Inline content
- Positioned descendants with
z-index: autoor0, in source order - Positioned descendants with positive
z-index, lowest first
So the debugging procedure for "z-index doesn't work" is: walk up from each of the two elements to find the stacking context each belongs to, find their ancestors that are siblings within the same context, and compare those. Chromium's devtools has a "Layers" panel, and Edge adds a "3D View" with a z-index mode, that visualise this.
Two practical defences:
-
Keep
z-indexvalues small and meaningful — a handful of named layers rather than 9999 races: -
Use
isolation: isolateon components that use z-index internally. It creates a stacking context with no visual side effects, so a component's internal layering can't leak out and fight the rest of the page.
And the modern escape hatch: the native <dialog> element opened with showModal(), and
the popover attribute, render in the top layer — above every stacking context on
the page, whatever the z-index values. Level 3 · 09 covers both; for modals and popups,
they make this whole class of bug disappear.
Common mistakes¶
- Absolute positioning without a positioned ancestor, so the element flies to the page corner.
z-indexon a static element (outside flex/grid) — it does nothing.- Escalating
z-indexto 9999 instead of finding the stacking context. - A transformed ancestor breaking
position: fixed. - Sticky inside
overflow: hidden— useoverflow: clip. - Absolute positioning for layout that Grid or Flexbox would handle; absolutely positioned elements don't affect siblings, so content overlaps as soon as text changes length.
Exercise¶
- Add a "New" badge to the top-right corner of a card. Remove
position: relativefrom the card and watch where it goes. - Make your recipe page's section headings sticky. Then wrap the article in a
divwithoverflow: hiddenand see stickiness break; fix it withoverflow: clip. - Recreate the modal/opacity test. Confirm the bug with
document.elementFromPoint, then fix it two ways: remove the stacking context, and move the modal to be a direct child ofbody. - Define z-index custom properties for your site's layers and replace any raw numbers.