Positioning and containing blocks.
Every offset you write, top 8px or right 0, is measured from some box. CSS calls that box the containing block, and finding it is a short walk up the tree with a few surprising stops. Get the walk right and dropdowns stop getting cut off, toasts stay in their corner and sticky headers stick.
Builds on Formatting contexts.
The idea.
Positioning is two questions: does this box keep its place in the flow, and what is it measured from? The second answer is called its containing block, and most layout bugs with dropdowns, toasts and sticky headers come from getting it wrong.
Open a task card on the Slatework board and press More. The menu is written as position: absolute with top: 100% and right: 0, which reads as 'just under the button, flush with its right edge'. That sentence hides a question. 100% of what, and the right edge of what? CSS answers with the containing block: a rectangle chosen by walking up from the menu until an ancestor qualifies. For an absolutely positioned box the walk stops at the first ancestor whose position is anything but static, and the offsets are measured from that ancestor's padding box.
The same walk decides two more things that look unrelated. Whether a scrolling or clipping ancestor may cut the menu off depends on whether the menu's containing block sits inside that ancestor. And whether position: fixed really means 'fixed to the window' depends on whether some ancestor carries a transform, a filter or containment, because any of those quietly turns that ancestor into the containing block for fixed descendants too.
So in Slatework the menu is cut off at the bottom of the card, the 'Card moved' toast drifts up the page when you scroll, and the column header refuses to stick. Three symptoms, one habit that fixes them all: before touching z-index or adding !important, name the containing block.
The More menu, cut off by its own card
The menu's containing block is inside the card body, and the card body clips. Everything below the card's bottom edge is lost, including Archive.
Menu opens. Box layout “app.slatework.example/projects/atlas/board”: 7 shapes. View: 432 × 344 from -16, -28. Viewport: 400 × 300 at 0, 0. 1. “body” (frame) 400 × 300 at 0, 0 2. “div.card” (rect) badge “position: relative” 232 × 150 at 24, 24 in body 3. “Fix login redirect” (rect) 140 × 22 at 36, 34 sand in div.card 4. “div.card-body” (rect) badge “overflow: hidden” 232 × 110 at 24, 64 in div.card clips its children (overflow: hidden) 5. “description” (rect) 150 × 54 at 36, 104 sand in div.card-body 6. “More” (rect) 44 × 24 at 200, 72 blue in div.card-body 7. “ul.menu” (rect) badge “absolute; top: 100%” 120 × 132 at 124, 100 orange in div.card-body Clipped (the hidden parts drawn dashed): ul.menu partly by div.card-body. Note on shape:menu: Archive is in the hidden part
- The More menu4MoreMenu
- Clipping card body3TaskCard
The five position values at a glance
| position | Keeps its space in the flow? | Offsets and percentages measured from | When the page scrolls |
|---|---|---|---|
| static | Yes; top, left and friends are ignored | Content box of the nearest block container (for percentage widths, heights and margins) | Moves with its parent |
| relative | Yes; drawn shifted, but the gap stays where it was | Its own normal position (the shift); percentages from the same content box as static | Moves with its parent |
| absolute | No; the rest of the flow closes up | Padding box of the nearest ancestor that is not static (or the initial containing block) | Moves with that ancestor |
| fixed | No | The viewport, unless an ancestor has transform, filter, contain and a few others | Stays put, unless such an ancestor has captured it |
| sticky | Yes, always | Laid out like relative; sticks within the nearest scrolling ancestor and never leaves its parent | Scrolls until it reaches its inset, holds, then is pushed off by its parent's end |
How it works.
One walk up the tree, with a different stopping rule per position value. Then two consequences of where the walk stopped: who may clip the box, and what it moves with when the page scrolls.
Finding the containing block
For a box in the flow (static, relative or sticky) the containing block is the content box of the nearest ancestor that is a block container or starts a formatting context: a block, a list item, a flex or grid container, a table cell. That is the box its percentage widths and margins come from, and it is almost always the parent you expect.
An absolute box skips every static ancestor and stops at the first one whose position is relative, absolute, fixed or sticky. It uses that ancestor's padding box, so an offset of 0 lands just inside the border, on top of the padding. If no ancestor qualifies, the walk ends at the initial containing block, a rectangle the size of the viewport anchored at the top of the document.
A fixed box normally skips the whole tree and uses the viewport. The exception is the one that surprises people: an ancestor with a transform, an individual translate, rotate or scale, perspective, filter, backdrop-filter, contain set to layout, paint, strict or content, will-change naming one of those, or content-visibility auto becomes the containing block for absolute and fixed descendants alike. The walk for fixed stops there, and so does the walk for absolute, even if that ancestor is static. (Browsers have not always agreed on perspective and filter, so test those two rather than relying on them.)
Where the More menu's walk stops
5 steps. Step through to see what one change does to these components.
Same card, two containing blocks
- width 1, Border
- 16, width 16, Padding
- content 286, width 286, Content
- 16, width 16, Padding
- width 1, Border
- static child starts here, cursor at 17
- content box: 286, from 17 to 303
- Border
- Padding
- Content
As it starts. 3 steps follow.
Ancestors that capture absolute and fixed descendants
| Property on the ancestor | Why it is there in real apps | What a fixed child inside it does |
|---|---|---|
| position other than static | The usual, deliberate anchor for absolute children | Nothing: fixed ignores positioned ancestors |
| transform, translate, rotate, scale (not none) | Slide-in routes, drawers, card hover lifts, centring tricks | Measured from this box and scrolls with it |
| perspective (not none) | 3D flips and tilts | Same as transform |
| filter, backdrop-filter (not none) | Dimmed or blurred backgrounds, frosted top bars | Same as transform |
| contain: layout, paint, strict or content | Performance isolation of a widget or a list row | Same as transform |
| will-change naming one of the above | Animation hints left on after the animation | Same as transform, before anything moves |
| content-visibility: auto | Skipping work for off-screen sections | Same as transform |
Who may clip a positioned box
overflow hidden, clip, auto or scroll on a box cuts off its descendants at its padding edge, with one exception written into the spec: a descendant whose containing block is the viewport, or an ancestor of the clipping box, is not cut. Read it as a question about the walk. If the positioned box's walk stopped inside the clipper (at the clipper itself or something below it), the clipper wins. If it stopped above the clipper, the clipper has no say.
That is why Slatework's menu is clipped: div.more is positioned and sits inside the card body. It also explains a confusing half-fix. Removing position from div.more and the body moves the containing block up to the card, so the body stops clipping, yet the menu is still cut at the bottom of the column, because the column scrolls (overflow-y auto) and the card is inside it. Every scroll container between the trigger and the root is another clipper, and on a board there are usually two or three.
Note also that hidden, auto and scroll cannot clip one axis alone: when one axis uses them and the other is left visible, the visible one computes to auto. (Only overflow-x or overflow-y set to clip can leave the other axis visible.) A column with overflow-y auto therefore clips sideways too, which is why a menu that pokes out of a column's right edge is cut there as well.
The menu as shipped, and the half-fix
.column { overflow-y: auto; } /* clipper 1 */
.card { position: relative; }
.card-body { position: relative; /* holds the fade overlay */
overflow: hidden; } /* clipper 2 */
.more { position: relative; } /* containing block */
.more-menu { position: absolute;
top: calc(100% + 4px);
right: 0; }
.card-body { overflow: hidden; } /* no longer positioned */
.more { } /* no longer positioned */
.more-menu { position: absolute; /* containing block: .card */
top: 56px; /* now measured from the card */
right: 12px; }
When fixed stops being fixed
The toast that scrolls away
At scroll 0 the shell sits exactly where the viewport is, so the toast looks fixed. The bug is invisible until someone scrolls.
Top of the page. Box layout “app.slatework.example/projects/atlas/board”: 7 shapes. View: 432 × 604 from -16, -28. Viewport: 400 × 260 at 0, 0. 1. “body” (frame) 400 × 560 at 0, 0 2. “div.app-shell” (rect) badge “transform: translateX(0)” 400 × 560 at 0, 0 in body 3. “header.topbar” (rect) 400 × 40 at 0, 0 sand in div.app-shell 4. “div.board” (rect) 368 × 480 at 16, 56 in div.app-shell 5. “To do” (rect) 168 × 456 at 28, 68 blue in div.board 6. “Doing” (rect) 168 × 456 at 204, 68 blue in div.board 7. “Card moved. Undo” (rect) badge “fixed, inside the shell” says “position: fixed” 156 × 40 at 228, 204 green in div.app-shell Clipped (the hidden parts drawn dashed): body partly by the viewport; div.app-shell partly by the viewport; div.board partly by the viewport; To do partly by the viewport; Doing partly by the viewport.
- Shell with a leftover transform1AppShell
- Undo toast5Toast
The transform does not have to move anything. translateX(0), scale(1) and a will-change: transform hint left on for 'smoothness' all count, because the rule looks at the computed value, not at the visual result. The usual ways in: a route transition or drawer that animates transform and leaves its final value set, a filter: blur(4px) on the page while a modal is open, and contain: paint added to a widget for speed. The same ancestor also becomes a stacking context, which is the next topic's subject.
Two fixes, depending on whether you own the ancestor. If you do, let the effect end at none: animate with a class that is removed on animationend, and drop the will-change hint after the animation. If you do not, move the fixed element out of the subtree: render the toast region as a direct child of body (a portal in component terms), or put it in the top layer as a popover, which ignores every ancestor's transform.
What sticky needs to stick
A sticky box is laid out exactly like a relative one and keeps its space in the flow. Then, as its scroll container scrolls, the browser shifts it so it never crosses the inset you gave it (top: 0 means 'not above the top edge of the scrollport'). Three conditions must hold, and each failure looks the same: the box scrolls away as if sticky were ignored.
- An inset on the axis it should stick in. top or bottom for vertical scrolling, left or right (or the inline-start and inline-end forms) for horizontal. With every inset auto there is no threshold to hold.
- The right scroll container. A sticky box sticks to the nearest ancestor with a scrolling mechanism, meaning overflow hidden, scroll or auto, even when that ancestor never actually scrolls because it is as tall as its content. A wrapper with overflow auto 'just in case' silently becomes the scrollport, and since it never scrolls, nothing sticks while the page scrolls past it. overflow clip does not create a scroll container, so it does not capture sticky boxes.
- Room in its parent. The box may move only while it stays inside its containing block, its parent's content box. A heading in a section that is barely taller than the heading has nowhere to go: it sticks for a few pixels and is pushed off.
Column headers that will not stick
Both headers carry position sticky and top 0. At scroll 0 there is nothing to tell yet.
Top of the page. Box layout “app.slatework.example/projects/atlas/board”: 11 shapes. View: 432 × 644 from -16, -28. Viewport: 400 × 240 at 0, 0. 1. “body” (frame) 400 × 600 at 0, 0 2. “div.board-wrap” (rect) badge “overflow: auto” 368 × 544 at 16, 40 in body 3. “section.todo” (rect) 170 × 528 at 24, 48 in div.board-wrap 4. “Fix login redirect” (rect) 154 × 60 at 32, 84 blue in section.todo 5. “Draft release notes” (rect) 154 × 60 at 32, 152 blue in section.todo 6. “Rename billing plan” (rect) 154 × 60 at 32, 220 blue in section.todo 7. “Audit invite emails” (rect) 154 × 60 at 32, 288 blue in section.todo 8. “h3 To do” (rect) badge “sticky; top: 0” 170 × 28 at 24, 48 purple in section.todo 9. “section.done” (rect) 170 × 140 at 206, 48 in div.board-wrap 10. “Ship the dark theme” (rect) 154 × 60 at 214, 84 green in section.done 11. “h3 Done” (rect) badge “sticky; top: 0” 170 × 28 at 206, 48 purple in section.done Clipped (the hidden parts drawn dashed): body partly by the viewport; div.board-wrap partly by the viewport; section.todo partly by the viewport; Rename billing plan partly by the viewport; Audit invite emails entirely by the viewport.
- Column header2BoardColumn
When a sticky box scrolls away anyway
| What you see | Cause | Fix |
|---|---|---|
| Never sticks, anywhere | No inset in the scrolling axis (top, bottom, left and right all auto) | Set top: 0 (or the height of whatever sits above it, such as a sticky top bar) |
| Never sticks while the page scrolls | An ancestor between it and the page has overflow hidden or auto and does not scroll itself | Remove that overflow, use overflow: clip where only clipping was wanted, or make that ancestor the real scroller with a fixed height |
| Sticks briefly then leaves | The parent is barely taller than the sticky box, so its end pushes the box out | Move the sticky box up a level, to a parent that spans the content it labels |
| Sticks under the top bar | top: 0 is measured from the scrollport, and the sticky top bar covers it | Offset by the bar: top equal to the bar height, ideally from one custom property shared by both |
In practice.
The More menu is Slatework's dropdown case study: get it out from under every clipper, place it next to its button, and flip it when the window has no room below. The same moves apply to every floating surface on the board.
The Slatework board and its floating surfaces
Every card's body clips its description, and every column scrolls on its own. Both are reasons a menu drawn in place gets cut.
Board. Board: 7 items. Column “To do”: 1. Fix login redirect by Ines R. badges: Bug. 2. Draft release notes by Tomas K. 3. Audit invite emails badges: Security. Column “Doing”: 1. Rename billing plan by Ines R. 2. Board keyboard shortcuts by Priya N. badges: Feature. Column “Review”: 1. Audit colour contrast by Dev S. Column “Done”: 1. Ship the dark theme by Theo B.
- Task card with a clipping body3TaskCard
- Scrolling column2BoardColumn
Case study: one menu, three placements
The menu's containing block is inside the card body, which is inside the column. Both cut it, and the window would cut it next.
In place. Box layout “app.slatework.example/projects/atlas/board”: 7 shapes. View: 432 × 400 from -16, -28. Viewport: 400 × 300 at 0, 0. 1. “body” (frame) 400 × 300 at 0, 0 2. “section.column” (rect) badge “overflow-y: auto” 208 × 268 at 16, 16 in body clips its children (overflow: hidden) 3. “Fix login redirect” (rect) 192 × 100 at 24, 48 blue in section.column 4. “Audit invite emails” (rect) badge “position: relative” 192 × 110 at 24, 168 in section.column 5. “div.card-body” (rect) badge “overflow: hidden” 192 × 82 at 24, 196 in Audit invite emails clips its children (overflow: hidden) 6. “More” (rect) 44 × 22 at 164, 204 blue in div.card-body 7. “ul.menu” (rect) badge “absolute” 112 × 110 at 96, 230 orange in div.card-body Clipped (the hidden parts drawn dashed): ul.menu partly by section.column and div.card-body and the viewport.
- More menu4MoreMenu
Does the menu fit below?
- Viewport height
- 800 pxa laptop window
- More button top
- 612 pxfrom getBoundingClientRect()
- More button bottom
- 640 px
- Menu height
- 220 pxfour items and padding
- Gap between button and menu
- 4 px
- Room below the buttonvh − btn-bottom = 800 − 640160 pxfrom Viewport height and More button bottom
- Room the menu needsmenu-h + gap = 220 + 4224 pxfrom Menu height and Gap between button and menu
- Room above the buttonbtn-top − 0612 pxfrom More button top
- Placement160 < 224 and 612 ≥ 224flip abovefrom Room below the button, Room the menu needs and Room above the button
- Menu top when flippedbtn-top − gap − menu-h = 612 − 4 − 220388 pxfrom More button top, Gap between button and menu and Menu height
- Flip on the block axis first; shift along the inline axis second, so a menu near the right edge slides left by the overflow plus a small padding instead of jumping sides.
- If neither side fits (a short phone window), cap the menu's height to the larger side and let it scroll inside, rather than letting the window cut it.
- The numbers change with every scroll and resize, so the check has to run again whenever either happens while the menu is open.
The More menu's placement, while it is open
States of4MoreMenu
Placement is not decided once. Every scroll or resize sends the menu back to measuring, and a button scrolled out of view hides the menu rather than leaving it floating over unrelated cards.
| From → To | Event | Guard | Action |
|---|---|---|---|
| Closed → Measuring | click More | ||
| Measuring → Open below | measured | room below | |
| Measuring → Open above (flipped) | measured | no room below | flip-block |
| Measuring → Hidden (button out of view) | measured | button clipped away | |
| Open below → Measuring | scroll or resize | ||
| Open above (flipped) → Measuring | scroll or resize | ||
| Hidden (button out of view) → Measuring | scroll or resize | ||
| Open below → Closed | pick, Escape or click outside | ||
| Open above (flipped) → Closed | pick, Escape or click outside | ||
| Hidden (button out of view) → Closed | click outside or route change |
- Closedstart
- Measuring
- Read the button's rectangle and the menu's size
- Open below
- The default placement
Two ways to ship the fix
<button class="more" popovertarget="menu-sw142">More</button>
<ul class="more-menu" id="menu-sw142" popover>
<li><button>Edit</button></li>
<li><button>Move to…</button></li>
<li><button>Copy link</button></li>
<li><button>Archive</button></li>
</ul>
<style>
/* popovertarget makes the button the menu's implicit anchor;
without it: anchor-name: --more on the button and
position-anchor: --more on the menu */
.more-menu {
inset: auto; /* replace the popover's centred defaults */
margin: 4px 0; /* (inset: 0, margin: auto) with a 4px gap */
position-area: bottom span-left; /* below, right edges lined up */
position-try-fallbacks: flip-block;
position-visibility: anchors-visible; /* the default, written out: hide when More scrolls out of view */
max-height: 60vh;
overflow-y: auto;
}
</style>
import { computePosition, autoUpdate, offset, flip, shift } from '@floating-ui/dom';
// menu is rendered as a child of <body>, outside every clipper
function openMenu(button, menu) {
menu.hidden = false;
const stop = autoUpdate(button, menu, async () => {
const { x, y, placement } = await computePosition(button, menu, {
strategy: 'fixed',
placement: 'bottom-end',
middleware: [offset(4), flip(), shift({ padding: 8 })],
});
Object.assign(menu.style, { left: `${x}px`, top: `${y}px` });
menu.dataset.placement = placement; // 'top-end' after a flip
});
return () => { stop(); menu.hidden = true; };
}
// Take this path only where anchor positioning is missing
const needsFallback = !CSS.supports('position-area', 'bottom');
The three Slatework bugs, as you would explain them
The same moves in the systems on this track
Trade-offs.
Two decisions for every floating surface, where it lives in the tree and who computes its position, plus a short list of the ways each choice goes wrong.
- Pro:Escapes every clipper and every transformed ancestor, with the viewport as its containing block
- Pro:Stays next to its trigger in the DOM, so reading order and event bubbling still make sense
- Pro:Light dismiss (click outside, Escape) comes with the popover attribute
- Pro:Gets an implicit anchor from popovertarget
- Con:Inherits font and colour from the card it is written in, which the menu may not want
- Con:Top-layer order is by opening order, not z-index (the next topic)
- Con:Placement still needs anchor positioning or JS
DOM order no longer matches visual order; focus and bubbling need care; Captured again if body or html gets a transform or filter (a modal blur); Needs measured coordinates and updates on every scroll and resize; Inherits only from body, so themed tokens must be global
Clipped by every overflow ancestor its containing block sits inside; Breaks again the day someone adds overflow or contain to an ancestor; Cannot flip past the edge of its scroll container
- Pro:Declarative: position-area plus position-try-fallbacks covers flip in a few lines
- Pro:The browser keeps it attached during layout, no scroll listeners
- Pro:Baseline since September 2026, in Chromium since version 125
- Con:Browsers older than that still need the fallback path for now
- Con:Some tactics (shift by a padding, size to the larger side) still need more CSS or a custom @position-try
- Con:Debugging which fallback won means inspecting computed styles
Ships JavaScript and runs on every scroll and resize; Runs after layout, so a slow main thread can leave the menu a frame behind its button
Re-implements flip, shift, clipping ancestors and scroll listening, usually with edge cases missed; Easy to measure in the wrong coordinate space (page versus viewport versus containing block)
Fixed or sticky for things that should stay on screen
| Need | Better choice | Why |
|---|---|---|
| Top bar on every route | sticky; top: 0 | Keeps its space in the flow, so nothing needs a padding-top equal to its height; stays put as long as no ancestor of it has overflow |
| Column or table headers | sticky; top: 0 | Sticks within its own column and is pushed off when the column ends |
| Toasts and a back-to-top button | fixed, or a popover | Belongs to the window, not to any section; must sit outside transformed or filtered ancestors |
| Dropdowns and tooltips | popover, anchor-positioned | Needs to escape clippers and follow an element |
| Badge on a card corner | absolute in a relative card | Moves and clips with the card on purpose |
How positioned surfaces break in production
| Failure | Impact | Detection | Mitigation | Meanwhile |
|---|---|---|---|---|
| A new overflow or contain rule on an ancestor clips an in-place menu4MoreMenu | The last items (often the destructive one) cannot be reached; users report 'Archive is missing' | A visual regression test that opens every menu on the lowest card of a full column | Render floating surfaces in the top layer or a portal so ancestor rules cannot reach them | Keyboard users can still arrow to hidden items, but cannot see them |
| An ancestor gains transform, filter or will-change and captures a fixed element5Toast | The toast or a fixed composer scrolls away; Undo is never seen | An end-to-end test that scrolls the page and asserts the toast's rectangle is unchanged | Keep the toast region outside the app shell; end animations at none and remove will-change afterwards | The message still exists in the page, only off screen |
| Sticky header silently disabled by an overflow wrapper2BoardColumn | Column names scroll away on long columns | Lint or review for overflow on ancestors of sticky elements | Prefer overflow clip where only clipping is wanted; document which element is the scroller | Purely cosmetic, but cards lose their column label while scrolling |
| A portaled menu is placed once and never updated4MoreMenu | Scrolling the column leaves the menu hanging over other cards, detached from its button | Open a menu, scroll its column, compare the menu's and button's rectangles | Re-run placement on scroll and resize (autoUpdate) or use anchor positioning; hide when the button leaves view | Users close and reopen the menu to get it back in place |