Positioning and stacking
Five values for position, one model for stacking — and the end of z-index superstition.
You drop a tooltip near a button. You write position: absolute, set top: -8px; right: 0, and the tooltip flies to the top-right corner of the page — not the corner of the button. You add z-index: 9999 to push it above everything else. It still slips under a sibling. None of this is broken CSS. The position property is doing exactly what it always does — and once the model clicks, "where is this thing going to land?" becomes answerable in your head, not by trial and error.
static and relative
Every element starts with position: static, which is the default. Static means "I sit where the normal flow puts me, and top / right / bottom / left do nothing on me." Most elements on most pages stay static their whole life. This is the boring base case, and it is fine.
position: relative changes one thing: the offset properties (top, right, bottom, left) now nudge the element from where it would otherwise sit. The element still takes up its original space in the layout — its slot does not collapse — but the visible box can be shifted from that slot.
.label {
position: relative;
top: -2px;
}That label moves up by 2 pixels visually, but the next sibling does not shift. The label's real slot is still where the layout placed it; the rendered box is just drawn 2 pixels above. This is why relative is good for tiny visual nudges and not for serious layout: the layout still thinks the element is in the original spot.
The other reason to use relative is more important. Setting position: relative on an element makes it the positioning context for any absolutely-positioned descendant. That sentence is the whole key to the next section. Read it twice.
absolute, and what it's relative to
position: absolute removes the element from the normal flow entirely. Its slot collapses — siblings move up to fill the gap — and the offset properties now position it against an ancestor. Specifically, the closest ancestor that has its own non-static position (so relative, absolute, fixed, or sticky). If there is no such ancestor, the element is positioned against the page (technically, the viewport's initial containing block).
This is why position: absolute; top: 0; right: 0 flies to the corner of the page — the parent didn't opt in to being a positioning context, so the browser kept walking up the tree until it found one (or ran out of tree).
The fix for the tooltip-on-the-button case is one line. Make the button the positioning context:
.button-wrap { position: relative; }
.tooltip {
position: absolute;
top: -32px;
right: 0;
}Now the tooltip is positioned relative to .button-wrap, and top: -32px; right: 0 lands it 32 pixels above the button-wrap's top edge, aligned to its right edge. This is the most common pattern in all of CSS positioning: "the parent is relative, the child is absolute."
A second consequence: an absolute element does not push siblings around. Drop an absolutely-positioned element into a card and the card's other children do not shift. That is the whole point — absolute elements live "above" the flow, not inside it.
.tag { position: absolute; top: 0; left: 0 } on a tag inside a card. The tag flies to the top-left of the page, not the card. What's the fix?fixed and sticky
position: fixed is like absolute, except the element is always positioned against the viewport — not against an ancestor — and it does not scroll with the page. A header with position: fixed; top: 0 stays glued to the top of the screen no matter how far the user scrolls. Useful, but heavy: a fixed element overlaps whatever is below it in the flow, so you usually need to add equivalent top padding on the body to compensate.
position: sticky is the friendliest one of the bunch. A sticky element behaves like relative until it is scrolled to a threshold, then it switches to behaving like fixed until its parent scrolls out of view. A <th> with position: sticky; top: 0 is the textbook use: it scrolls with the table normally, but pins to the top of the viewport once it tries to leave the screen, and lets go again when the table ends.
thead th {
position: sticky;
top: 0;
background: white;
}The sticky header sits at the top of the table normally. As the user scrolls past it, it sticks to the top of the viewport. Once they scroll past the table entirely, the header scrolls away with it. No JavaScript, no event listeners.
Two gotchas to know about sticky: it needs an explicit threshold (top: 0, bottom: 1rem, etc.), and it sticks within its scrollable parent. If a parent has overflow: hidden or overflow: auto, sticky will stick to that scroll container, not the page. If your sticky element is "not sticking", check whether some wrapper is silently creating a scroll context.
thead th { position: sticky; top: 0 }, but the header doesn't pin when you scroll. The first thing to check is:Stacking contexts and z-index
So far everything has been about where an element sits. The other half of positioning is which element is in front when two of them overlap. Default behaviour is simple: later in the source order is drawn on top. Add the z-index property to override that — higher numbers paint on top of lower numbers.
The catch — and the reason z-index: 9999 sometimes does not "win" — is stacking contexts. A stacking context is a self-contained layer. Inside it, z-index works as you expect; outside it, the entire context is treated as one unit, and the z-index of the context's own element is what competes with siblings.
Several CSS properties create a new stacking context. The common ones:
- A positioned element with a
z-indexvalue (other thanauto). - An element with
opacityless than 1. - An element with
transform,filter,perspective,will-changeset. - A flex or grid item with
z-indexset.
.card { transform: translateY(-2px); } /* creates a stacking context */
.card .tooltip { position: absolute; z-index: 9999; }
.modal { z-index: 100; }The tooltip with z-index: 9999 sits inside the card's stacking context. The modal with z-index: 100 is in a different (outer) context. The tooltip cannot escape the card's context, so it cannot rise above the modal — its 9999 is being compared only against siblings inside the card. From the outside, the whole card is one layer that competes at the card's own z-index.
The mental model that fixes this: a stacking context is a sealed box. Everything inside competes only with everything else inside. The whole box competes with its siblings as a single unit. If your z-index: 9999 "isn't winning," the question is almost always "what stacking context is this thing trapped in, and what is the z-index of that context?"
The practical fixes, in rough order of preference: (1) move the element out of the trapping ancestor in the DOM, (2) raise the z-index of the trapping ancestor itself, (3) drop the property that created the unwanted context (the transform, the opacity below 1) if it is not pulling its weight. There is no way to "punch through" a stacking context with a higher inner z-index — that is the part beginners chase for hours.
Pick a small set of z-index "tiers" early — say 10 (raised), 100 (overlay), 1000 (modal), 10000 (toast) — and stick to them. Big numbers like 9999 inside random components are a smell that someone fought a stacking-context battle and lost.
z-index: 100 appears behind a card that you only wrote transform: translateY(-2px) on. Why?