← back to the section

Positioning is responsible for the elements living "on top of" normal flow: dropdown menus, badges on cards, sticky headers, modal windows. position has five values, and the key is to understand not the values themselves, but relative to what the element moves and what happens to its place in the flow.

static and relative: the reference point

static — the default: the element sits in the flow, the top/right/bottom/left properties are ignored.

relative — the element stays in the flow (its place is preserved!), but is visually shifted relative to its original position:

.badge-icon { position: relative; top: -2px; } /* lift by 2px */

Relative shifts are used rarely. The real job of relative is different — being an anchor: a positioned element becomes the reference point for absolute descendants. position: relative without top/left is the most common line of positioning in real code.

absolute: relative to whom?

absolute does two things: it pulls the element out of the flow (its place collapses, siblings no longer see it) and positions it relative to the nearest positioned ancestor — the first parent up the tree whose position is not static.

.card { position: relative; }        /* anchor */
.card .badge {
  position: absolute;
  top: 8px;
  right: 8px;                        /* top right corner of the CARD */
}

Forgot relative on the card — the badge will measure from the next positioned ancestor, and if there are none, from the whole page, and fly off into the top right corner of the window. "The absolute element ended up in the wrong place" almost always means "the wrong anchor".

A useful idiom is stretching over the whole anchor: inset: 0 (shorthand for top/right/bottom/left: 0) — overlays, backdrops, clickable areas over a card. And note: with both top+bottom or left+right set, an absolute element can stretch, not only pin to a corner.

fixed and sticky: pinned to the screen

fixed — like absolute, but the reference point is the browser window: the element doesn't scroll with the page. Headers, floating buttons, cookie banners. It is pulled out of the flow — content under a fixed header needs a compensating offset. A nuance that will one day save you an hour: transform (and also filter) on an ancestor turns it into the reference point for fixed — the "fixed" element suddenly starts scrolling along with its animated parent.

sticky — a hybrid: the element rides along in the flow as usual until it reaches the given threshold — and sticks:

.section-header {
  position: sticky;
  top: 0; /* the threshold is mandatory — without it sticky does not work! */
}

Unlike fixed, its place in the flow is preserved (nothing gets overlapped), and the element sticks only within its parent — when the end of the section arrives, the header rides away with it. This is both a feature (table headers, alphabetical list dividers) and the source of the number one mystery: "sticky doesn't work". Checklist: (1) is top set; (2) isn't the parent exactly the element's height — nowhere to stick; (3) are there no overflow: hidden/auto on ancestors — they break sticking.

z-index and stacking contexts

Who overlaps whom? Basics: positioned elements sit above ordinary ones; on a tie, later in the markup means higher. Fine-tuning is z-index (it only works on positioned elements and on flex/grid children).

If it were that simple, z-index: 999999 wouldn't exist. The real rule: z-index is compared within a single stacking context. A context is created by: a positioned element with a z-index, an opacity below 1, transform, filter, will-change, and a dozen more properties. Inside a context, children are sorted among themselves, but the whole context participates in the outer sorting as a single layer:

<div class="dropdown-wrap"> <!-- opacity: 0.99 → created a context -->
  <div class="dropdown" style="z-index: 9999">...</div>
</div>
<div class="toolbar" style="position: relative; z-index: 10">...</div>

The dropdown with z-index 9999 will end up under the toolbar with z-index 10: its nine thousand acts only inside the parent context, and the parent context, against the toolbar, is a layer with no number. The cure is not piling on zeros but finding which ancestor created the context (DevTools to the rescue) — or moving the element up the tree (modal portals in React exist for exactly this reason).

Hygiene: keep a short project layer scale (say, 10 — sticky headers, 100 — dropdowns, 1000 — modals, 10000 — toasts) in CSS variables — and there will be no z-index wars.

In short

  • relative — a shift from its own place and, above all, an anchor for absolute children. Keeps its place in the flow.
  • absolute — out of the flow, measured from the nearest positioned ancestor. "Flew away" — look for where relative was forgotten. inset: 0 — stretch over the whole anchor.
  • fixed — pinned to the window; transform on an ancestor breaks this. sticky — rides and sticks within its parent; needs top, gets broken by ancestors' overflow.
  • z-index works within a stacking context; contexts are created by opacity/transform/filter and positioning with z-index. "9999 doesn't help" — an ancestor's context is to blame.
  • A layer scale in the project's variables is a vaccine against z-index wars.
  • Document flow — what exactly "pulled out of the flow" means.
  • Flexbox — the next topic: a layout in which positioning is needed less and less.
  • Animations: CSS and optimizations — transform, the creator of stacking contexts, in its leading role.