Skip to main content
CodeOath
← All posts

HTML & CSS82 min total · 18 parts

CSS Fundamentals: The Box Model, Specificity, Positioning, and Layout

Part 10 of 18 · ~4 min

Positioning and Stacking Contexts

Setting position does double duty: it decides whether an element still takes part in ordinary document flow at all, and separately, it decides whether top, right, bottom, and left mean anything on that element in the first place.

ValueBehavior
static (default)Ordinary document flow — offsets and z-index are ignored entirely
relativeStays exactly where flow would have placed it, but top/right/bottom/left can nudge it from that spot
absolutePulled out of flow completely; anchored to the closest ancestor that isn't position: static (falling back to the page's own root if none exists)
fixedAnchored to the browser viewport itself, so it holds its spot through any amount of scrolling
stickyOrdinary flow, like relative, until the scroll position crosses a threshold — then it locks in place like fixed, but only within its own containing block

The card needs two corner badges: a heart-shaped wishlist toggle in the top-right corner, and a "Sale" ribbon in the top-left. Both lean on a pattern that shows up in nearly every real layout that touches positioning at all: an absolute element paired with a relative ancestor whose only job is to give it something to measure from.

.card {
  position: relative; /* now everything absolutely positioned inside .card measures from here, not the page */
}
.card__wishlist {
  position: absolute;
  top: 10px;
  right: 10px;
}
.card__ribbon {
  position: absolute;
  top: 14px;
  left: -26px;
}

Ship the wishlist heart with a z-index intended to keep it above the product image, and it does nothing:

.card__wishlist {
  z-index: 999; /* has no effect */
}

A static element ignores z-index completely, whatever value you give it — and at this point in the story the badge still hadn't been assigned any position at all, so that 999 was doing nothing whatsoever. Give it position: absolute — which it already needs anyway, to sit pinned to the corner — and the z-index suddenly starts working. This particular gap shows up constantly in real stylesheets: a z-index that appears to do nothing, and the missing line is always the same one.

Stacking contexts

Treat z-index as scoped rather than global: two elements' z-index values only ever get compared against each other if they both belong to the same stacking context to begin with. A specific, fixed set of properties carves out a fresh one — sealing off the element that carries them, plus everything nested inside, so nothing in there ever gets weighed against a z-index from outside: a positioned element with a z-index other than auto, an opacity under 1, or a transform, filter, or will-change naming anything besides none.

Here's where that becomes a real bug on the card. Hovering a card lifts it slightly, a common enough effect:

.card:hover {
  transform: translateY(-4px) scale(1.02);
}

Nothing about that looks dangerous, and visually it isn't. But .card picking up a transform does two things at once, not one. It creates a new stacking context — expected, and harmless here. It also turns .card into a new containing block for any descendant that's position: fixed — and that part is not obvious at all, and it isn't limited to absolute children the way you'd guess from the positioning table above.

The card has exactly one fixed descendant: a confirmation toast that's already sitting in the markup, hidden, waiting to be shown the moment "Add to cart" is clicked.

.card__toast {
  position: fixed;
  bottom: 24px;
  right: 24px; /* meant to land in the corner of the SCREEN */
}

Click "Add to cart" while the cursor is still resting on the card — the ordinary way anyone actually clicks a button — and the toast doesn't appear in the corner of the screen at all. It appears jammed into the corner of the card. Nothing about .card__toast's own CSS changed. What changed is that .card is, at that exact moment, mid-hover with an active transform, which means it's no longer true that "fixed" measures from the viewport for anything living inside it — it measures from .card instead, because .card is now the containing block for every fixed descendant it has. The instant the hover ends and the transform clears, .card__toast goes back to normal — which is exactly why this bug is so hard to reproduce on purpose and so easy to run into by accident.

The fix is to stop the toast from being a descendant of the transformed element at all — render it at the end of <body> instead, outside .card entirely, and position it from there. position: fixed is only really "fixed to the viewport" as long as nothing between it and the root has a transform, filter, perspective, or will-change naming one of those properties. That's not a rare edge case; it's the standard mechanism behind portals, modals, and toast libraries choosing to render outside the component tree rather than inline within it.