top: 0; right: 0; on a badge means “put it in the top-right corner”, but of what? The answer is the element’s containing block, and which box that is depends on the element’s position value and its ancestors. Most positioning bugs (“the badge flew to the corner of the page”, “my fixed header scrolls away”, “my sticky header doesn’t stick”) are a wrong assumption about the containing block.
The containing block for each position value
static and relative. The containing block is the content box of the nearest block container ancestor, usually the parent. relative keeps the element in normal flow and then nudges it visually: top: 10px moves it 10px down from where it would have been, and the space it originally occupied stays reserved. Neighbours don’t move.
absolute. The element is taken out of flow, and its containing block is the padding box of the nearest ancestor whose position is anything other than static. If there’s no such ancestor, it’s the initial containing block, a viewport-sized rectangle anchored at the top of the document (so it scrolls with the page).
fixed. The containing block is the viewport, so the element stays put when the page scrolls. With one important exception, covered below.
sticky. The element stays in flow like relative, until scrolling would move it past the threshold you set (top: 0). Then it sticks, relative to its nearest scrolling ancestor, but it can’t leave its own parent’s box.
The badge, worked through
<article class="card">
<img src="/img/shoe.jpg" alt="Trail Runner 3" width="400" height="300">
<span class="badge">New</span>
<h3>Trail Runner 3</h3>
</article>.card {
padding: 16px;
border: 1px solid #e4e4e7;
}
.badge {
position: absolute;
top: 8px;
right: 8px;
}On the page, every card’s badge piles up in the top-right corner of the page (or of some unrelated positioned ancestor, like a layout wrapper someone gave position: relative years ago). None of the .card elements is positioned, so the badge looks further up the tree for its containing block.
The fix is one declaration:
.card {
position: relative;
}position: relative with no offsets doesn’t move the card at all; it only makes it a containing block for absolutely positioned descendants. Now each badge sits 8px from the top and right edges of its own card’s padding box, which means it overlaps the 16px of padding (the badge is inside the border, partly over the padding area). If you wanted it 8px inside the content area, you’d use top: 24px; right: 24px;. The padding-box rule is a frequent interview detail: absolute offsets measure from inside the border, not from inside the padding.
Percentages follow the containing block
Percentage values for width, padding, margin and the inset properties resolve against the containing block, not necessarily the parent. For an absolutely positioned element, that’s the positioned ancestor’s padding box:
.tooltip {
position: absolute;
left: 50%;
width: 50%;
}If the nearest positioned ancestor is a 600px-wide panel with 20px of padding (a 640px padding box), and the tooltip’s DOM parent is a 120px-wide button inside that panel, width: 50% gives 320px, not 60px. The tooltip ends up much wider than expected. Make the button position: relative if the tooltip should be sized and placed relative to it, or use width: max-content with a max-width so it sizes from its text.
Two more oddities that surface in quizzes: vertical padding and margin percentages resolve against the containing block’s width, not its height (this is the old “padding-top: 56.25%” trick for 16:9 boxes, now replaced by aspect-ratio). And height: 50% only works if the containing block has a definite height; otherwise it behaves like auto.
When fixed stops being fixed
A modal overlay with position: fixed; inset: 0; should cover the whole viewport. Place it inside a card that has a hover animation using transform, and it covers only the card, and scrolls with it.
The reason: any ancestor with transform (other than none), filter (other than none), perspective, backdrop-filter, contain: paint or layout, container-type other than normal, or will-change naming one of those becomes the containing block for fixed descendants as well as absolute ones. The fixed element is now positioned against that ancestor, not the viewport. This is per spec, not a browser bug, and it catches people constantly because the transform might be in a component library or an animation applied only on hover.
There are two fixes. Move the overlay out of the transformed ancestor, typically by rendering it at the end of <body> (React’s createPortal, Angular CDK’s overlay, or Vue’s Teleport exist largely for this). Or use the native <dialog> element with showModal(), which renders in the top layer, a layer above the whole document that ignores containing blocks, stacking contexts and overflow: hidden entirely. The top layer is also used by the Popover API.
Making sticky actually stick
position: sticky fails silently in three common ways:
- No threshold.
position: stickywithouttop,bottom,leftorrightbehaves exactly likerelative. You must set at least one, such astop: 0. - An ancestor with overflow. Sticky sticks within its nearest scrolling ancestor, meaning any ancestor with
overflowset tohidden,autoorscroll. If a wrapper hasoverflow: hidden(often added to contain floats or clip a shadow), it becomes the sticky element’s scroll container. That wrapper doesn’t actually scroll, so the element never sticks.overflow: clipdoesn’t create a scroll container, so it’s a safe replacement when you only want clipping. - A parent no taller than the element. A sticky element can’t leave its parent. If the parent is exactly as tall as the sticky header (a
<header>containing only the nav), there’s no room to stick. Putposition: stickyon the outer element whose parent is the long page body.
DevTools helps with all three: in Chrome, the Elements panel marks scroll containers with a “scroll” badge, so you can walk up from the sticky element and find the unintended one.
Interview practice
A dropdown menu inside a table cell uses position: absolute; top: 100%; left: 0;. It appears in the wrong place, at the top-left of the table wrapper, and it’s also clipped by the wrapper, which has overflow-x: auto for horizontal scrolling. Explain both symptoms and how you’d fix them.
Answer
The placement comes from the containing block. The dropdown’s nearest positioned ancestor is the table wrapper (presumably position: relative), not the cell or the trigger button, so top: 100% and left: 0 are measured against the wrapper’s padding box: 100% of the wrapper’s height down, at its left edge. Giving the element that wraps the trigger and menu position: relative makes it the containing block, and the menu then opens directly below the trigger.
The clipping is separate: an element with overflow other than visible clips absolutely positioned descendants whose containing block is that element or something inside it. Here the wrapper is the containing block, and after the first fix the containing block is inside the wrapper, so either way the menu is cut off at the wrapper’s edge. Only a containing block above the wrapper would let it escape, and that would put you back to the placement problem. The reliable fixes are to render the menu outside the wrapper (a portal to <body>, positioned with JavaScript using the trigger’s getBoundingClientRect() or a library like Floating UI), or to use the Popover API (popover attribute), which puts the menu in the top layer where overflow can’t clip it. CSS anchor positioning can then place it relative to the trigger in browsers that support it.