Set height: 50% on a box and it can vanish. Give three items flex: 1 and they come out different widths. Type z-index: 9999 and the thing still sits behind its neighbour. None of these is a bug. The browser decides where every box goes and how big it is by rules you were never actually shown — a whole layout algorithm running underneath the one line of CSS you wrote. This guide teaches that algorithm from the ground up: a single box first, then how boxes stack, size themselves, break out of flow, and rearrange for a phone — until you can look at any layout and predict where everything lands before you hit save, and build a nav bar or a dropdown that doesn't fight you. Every number here was measured in a real browser, not remembered. We start with one box.
The box, from the inside out
Give a panel a width of 400 pixels. Add some breathing room inside it — 20 pixels of padding — and a 5-pixel border so you can see its edge. Then measure how wide it actually is on the page.
.panel {
width: 400px;
padding: 20px;
border: 5px solid;
}
box-sizing:content-box -> border box width = 450
450, not 400. You asked for 400 and got 450, and nothing you typed says 450 anywhere. That gap — between the number you set and the number the browser drew — is the first thing to fix, because almost every "why is this the wrong size" afternoon starts here.
Here is what happened. Every element the browser draws is a box, and that box is four layers deep. Working from the middle out: the content sits in the centre. Around it is the padding, space held clear on the inside. Around that is the border. And around the whole thing is the margin, the gap it keeps from its neighbours.
width sets the innermost one.The trap is in that last diagram. When you write width: 400px, you are setting the width of the content box — the innermost one. The padding and border are then added outside that. So the box the browser paints is 400 + 20 + 20 + 5 + 5 = 450 wide. Padding and border push the visible edge outward; they don't eat into the 400 you asked for.
The margin is the odd one out. It sits outside the border and it is not part of the box's own width at all — it's the space the box demands from whatever sits next to it. Set margin: 30px and the box is still 450 wide; it just refuses to let anything come within 30 pixels of it. That distinction matters later, when margins start doing things padding never would.
Making width mean what it says
Doing that arithmetic in your head every time you touch padding is exactly the tax it sounds like. So there's a switch that flips it. Set box-sizing: border-box, and width now addresses the border box instead of the content box.
.panel {
box-sizing: border-box;
width: 400px;
padding: 20px;
border: 5px solid;
}
box-sizing:border-box -> border box width = 400, content shrinks to 350
Now width: 400px means the box is 400 wide, full stop. The padding and border don't grow it — instead the browser shrinks the content to make room, down to 400 − 40 − 10 = 350. The number you typed is the number on the screen, and the padding comes out of the inside.
That is why nearly every stylesheet, and every CSS framework, opens by setting box-sizing: border-box on everything. It's not a preference; it's choosing the arithmetic where the width you write is the width you get. The default, content-box, is the one that surprised you at the top of this page.
So the four boxes have names — content box, padding box, border box, margin box — and box-sizing is the choice of which one width and height are talking about. That's the whole atom. Everything from here is about where these boxes go on the page and how big they decide to be, and both of those turn out to be less obvious than a single box makes them look.
How boxes stack: normal flow
Put two <div>s inside a 400-pixel-wide container, each holding a couple of words. Then put two <span>s after them, the same short text. Measure where all four land.
<div class="wrap"> <!-- width: 400px -->
<div>first block</div>
<div>second block</div>
<span>a span</span><span>another span</span>
</div>
block #1 : width 400, x 0, y 0
block #2 : width 400, x 0, y 18 <- new line, full width again
span #1 : width 40.44, x 0, y 36
span #2 : width 81.31, x 40.44, y 36 <- same line as span #1
Two things to notice, and they're the whole chapter. The divs are 400 wide — the full container — even though "first block" is a handful of characters. And they stack: block #2 sits directly below block #1 (same x, larger y). The spans do the opposite. Each is only as wide as its own text, and span #2 sits beside span #1 on the same line, not below it.
Nobody sets this up. It's the layout you get before you reach for anything — no flexbox, no grid, no positioning. The browser places boxes one after another using two default behaviours, and knowing which one is running is half of "why won't this move".
Two personalities: block and inline
Every box in this default layout is one of two kinds.
A block box takes a line of its own. It stacks vertically with its siblings, top to bottom, and by default it stretches sideways to fill the width available to it. That's why both divs came out 400 wide: a block's width starts at auto, and auto for a block in flow means "as wide as there's room for". <div>, <p>, <h1>, <section> — all block.
An inline box doesn't get its own line. It sits inside the run of text, only as wide as its content, and flows left to right until it runs out of room and wraps to the next line like words in a paragraph. That's why the two spans shared a line and each was just wide enough for its letters. <span>, <a>, <strong>, <em> — all inline.
The name for this default arrangement is normal flow (or just flow layout): block boxes stacking down, inline boxes flowing across. It is the ground state of every page, and it's still running underneath flexbox and grid once you add them.
The gotcha that follows from it
Here's the one that catches people. Try to give an inline <span> a width and a height.
span { width: 300px; height: 100px; }
span : width 66.2, height 18 <- both ignored, still content-sized
inline-block : width 300, height 100 <- both honoured
The span flat-out ignores both. It stayed the width of its text and one line tall. An inline box is its content — there's no width to set, because the box only exists to hold letters in a line. So the answer to "why won't my <span> take a width" is: because it's inline, and inline boxes don't have one.
The fix is the third value in that output: inline-block. Set display: inline-block and you get a box with a foot in each camp — it still flows inline, sitting beside its neighbours the way a span does, but you can size it like a block. It honoured the full 300 × 100. That combination — flows across, yet sizable — is what old navigation bars were built out of before flexbox existed, and it's the first sign that display is the property that chooses how a box behaves, not just how it looks.
So normal flow gives you two moves for free: things that stack and fill, and things that line up and hug. Most of what comes next is a way to arrange boxes that flow neither of those two default ways — but it all sits on top of this, and when a layout does something you didn't ask for, "is this thing block or inline?" is the first question worth asking.
The margins that merge
Take a plain <div> and put one child inside it. Give the child a margin-top: 40px and a height of 20. You'd expect the parent to end up 60 tall — 40 of margin pushing the child down, then 20 of child. Measure it.
<div class="parent">
<div class="child"></div> <!-- margin-top: 40px; height: 20px -->
</div>
plain block parent : height 20, child offset from parent top = 0
The parent is 20 tall, not 60. And the child sits flush against the parent's top — its offset is 0, as if the 40px margin weren't there. The margin didn't push the child down inside the parent. It escaped through the top of the parent and pushed the parent down instead.
This is the everyday version of it: you add margin-top to a heading to nudge it down, and the whole card it lives in slides down instead, with the heading still stuck to the top. Same bug, and it looks like the margin is landing on the wrong element.
Now stack two boxes as siblings. Give the first a margin-bottom: 30px and the second a margin-top: 50px. The gap between them should be 80. Measure that too.
margin-bottom:30 then margin-top:50 -> gap = 50
50, not 80. Not 40 either. When two vertical margins meet, they don't add up — they overlap, and you get the larger of the two.
Margins collapse
Both results are the same rule, called margin collapsing: adjoining vertical margins in normal flow merge into one, whose size is the largest of them. A child's top margin and its parent's top margin are adjoining, so they merge and the result sits outside the parent. Two stacked siblings' facing margins are adjoining, so 30 and 50 become a single 50.
It sounds like a bug and it's a feature, left over from laying out documents. It's what makes a stack of paragraphs come out evenly spaced instead of with doubled gaps wherever a margin-bottom meets a margin-top. It only happens vertically, and only in normal flow — horizontal margins never collapse, and neither does anything once you leave flow layout.
One thing stops all of it
You stop a margin escaping by putting something in its way. Anything at all between the parent's edge and the child's margin does it. Here are five attempts, same setup, measured:
| Parent has… | Parent height | Child offset | Collapse? |
|---|---|---|---|
| nothing (plain block) | 20 | 0 | escaped |
border-top: 1px |
61 | 41 | blocked |
padding-top: 1px |
61 | 41 | blocked |
overflow: hidden |
60 | 40 | blocked |
display: flow-root |
60 | 40 | blocked |
display: flex |
60 | 40 | blocked |
A single pixel of border or padding is enough — it's now physically between the two margins, so they aren't adjoining. But look at the last three. overflow: hidden, display: flow-root, and display: flex all stop the collapse too, and none of them puts a pixel anywhere. They do something subtler, and it's the same something in all three.
Each of those turns the parent into its own sealed layout world. Inside that world, layout happens by its own rules and doesn't leak out; outside it, nothing leaks in. A margin can't collapse across the wall of one, so the child's margin is trapped inside the parent and pushes the child down within it — exactly what you wanted in the first place.
That sealed world has a name — a formatting context — and you'll meet it properly a few times over. Here's the same wall from the other side, to show it's a real thing and not a story. Float a child left — floating pulls it out of normal flow, so a plain parent shrinks as if the child weren't there — and the parent collapses to zero height. Turn the parent into one of those sealed worlds and it contains the float again, springing back to full height.
plain block parent : height 0 <- the float escaped
display:flow-root : height 60 <- contained
overflow:auto : height 60 <- contained
So overflow: hidden "fixing" a collapsed margin and overflow: hidden "fixing" a parent that won't wrap its float are not two tricks to memorise. They're one property doing one thing — starting a formatting context — seen from two sides. Hold onto that. The same handful of triggers will keep showing up, and once you can name the mechanism, you stop collecting tips for each symptom.
What is that a percentage of?
Two rules that look like twins. Give a box width: 50%, and separately give a box height: 50%. Both sit in a 300-wide, 200-tall parent. Measure what you get.
.half-wide { width: 50%; }
.half-tall { height: 50%; }
width:50% of a 300px parent -> 150 (as expected)
height:50% of an auto-height parent -> 0 (the box vanishes)
width: 50% gives you 150, exactly what you'd guess. height: 50% gives you nothing — the box collapses to zero and takes the parent down with it. Same percentage, same parent, and one of them deletes your element. That's the "I set a height and it disappeared" bug, and it's worth understanding rather than reaching for a workaround.
Fifty percent of what?
A percentage is never a size on its own. It's always a percentage of something, and the whole question is what that something is. For both width and height, the "something" is the parent's matching dimension. So width: 50% is half the parent's width, and height: 50% is half the parent's height. So far they're symmetric.
They stop being symmetric because of where each parent gets its own size.
A block's width is settled early. Even a plain <div> with nothing set has a width — it stretches to fill the space available, which you saw back in normal flow. So when the child asks "what's 50% of the parent's width?", the answer already exists: 300, so 150. The parent's width doesn't depend on the child at all.
A block's height works the other way around. By default a box is exactly as tall as its contents make it — no more. So the parent's height depends on the child. But now look at what you asked for: the child's height is 50% of the parent's height. The parent is waiting to find out how tall the child is; the child is waiting to find out how tall the parent is. Each is asking the other to go first.
The browser doesn't hang on that. It breaks the loop by refusing the percentage: a percentage height whose parent has no set height is treated as auto, which means "size to your content". The child has no content, so auto is 0. The parent, now sized around a zero-height child, is also 0. That's the whole collapse.
Definite and indefinite
Here are the two words for it, now that you've felt the thing. A size is definite when the browser can know it without first laying out what's inside — an explicit length like 200px, or a percentage of something that's already definite. A size is indefinite when it depends on the contents, the way a plain block's height does.
The rule is one line: a percentage resolves only against a definite base. Width usually has one, so width percentages "just work". Height usually doesn't, so height percentages usually don't.
Which tells you exactly how to fix it — give the parent a definite height, so the base exists. Any of these does it, all measured:
| Parent | Child height: 50% |
|---|---|
height: auto (default) |
0 |
height: 200px |
100 |
height: 200px; display: flex |
100 |
height: 200px; position: relative + child position: absolute |
100 |
The moment the parent has a real height — a fixed 200px, or a height it got by being a stretched flex item, or a height an absolutely-positioned child measures against — the base is definite and 50% resolves to 100. You'll meet flex and positioning properly soon; the point here is only that they each hand the child a definite height to work from, and that's all the percentage ever needed.
So when a height percentage evaporates, don't reach for a hack. Walk up one level and ask the only question that matters: does the parent have a definite height for this to be a percentage of? If it doesn't, nothing you set on the child will help, because the child was never the problem.
How big does it want to be?
You want a button that's exactly as wide as its label — "Save changes", no more. But you built it out of a <div>, and a div is a block, so it stretches across the whole line. Here's the same label, three ways, in a 600-pixel row.
.block { /* default */ } /* fills the row */
.hug { width: fit-content; } /* hugs the text */
default block -> 600 (fills the available space)
width: fit-content -> 229.16 (only as wide as the words need)
The block takes all 600. fit-content takes 229.16 — the width the text actually wants and not a pixel more. So far, so useful: that keyword is how you make anything hug its content. But fit-content is the interesting one of a set of three, and the other two are what flex and grid quietly compute all day long, so they're worth meeting head-on.
The content has a natural range
Every piece of content has two natural widths, and they're the widest and narrowest it can honestly be.
max-content is how wide it would be if it never had to wrap — the whole thing laid out on one line. For "Update notification preferences" that's 229.16. It's the content's ideal, greedy width: give it all the room in the world and this is what it takes.
min-content is the opposite: the narrowest it can get before the content actually breaks. Text wraps as hard as it can, but a single word won't split down the middle, so the floor is the width of the longest word. Here that's "notification", at 86.72.
.narrowest { width: min-content; }
.widest { width: max-content; }
width: min-content -> 86.72 (the longest single word)
width: max-content -> 229.16 (the whole phrase, no wrap)
So this content is comfortable anywhere between 86.72 and 229.16 wide. Below 86.72 it can't go without a word spilling out; above 229.16 there's just empty space to its right. That range is a property of the content, not of the box — which is why these are called intrinsic sizes: they come from what's inside, as opposed to the extrinsic sizes you impose from outside, like 300px or 50%.
Note
The exact pixels here — 86.72, 229.16 — are what this text measures in this browser's font. On a machine with a different default font you'll get slightly different numbers; what's universal is the shape, with min-content never wider than max-content and the content settling somewhere in between. The pure-arithmetic figures elsewhere in the guide — box sizes, flex shares, fr splits — don't move with the font.
fit-content is the compromise
Now fit-content makes sense. It's "be your max-content width, but if there isn't room, don't overflow — stop at the space available." When there's room to spare it hugs the content; when there isn't it caps at what it's given and wraps inside. Watch it change its mind based purely on the room it's in:
fit-content, 600px available -> 229.16 (fits, so == max-content: hugs)
fit-content, 180px available -> 180 (doesn't fit, so caps at available)
Same content, same rule, two answers, because fit-content is really min-content and max-content and the available space clamped together: as big as the content wants, never smaller than it can bear, never wider than it's allowed. That's exactly what you want for a button, a tag, a chip — hug the label, but don't blow out of the container on a long one.
Here's why this chapter comes before flexbox and grid, even though you'll type min-content far less often than flex: 1. Those layout systems are built on these three sizes. When a flex item "refuses to shrink past its text" in the next chapter, the floor it's stuck on is its min-content size. When a grid column sized auto hugs its widest cell, that's max-content doing its job. When a <button> or a <span> hugs its label without you asking, that's the same intrinsic sizing that a block turns off by stretching to fill.
So the surprises coming up aren't magic. They're the browser sizing things to their content's natural range — a range that now has a name at each end, and a compromise in the middle.
Flexbox: rows, columns, the main axis
Everything so far has stacked down the page, because that's what block boxes do. But a nav bar is the other thing: a logo on the left, some links on the right, all in a row. Normal flow won't give you that without a fight. So here are three block divs, and then the one line that turns them from a stack into a row.
.row { display: flex; } /* was: three divs stacking vertically */
without display:flex : each div on its own line, stacked down
with display:flex : x positions [0, 80, 160] — a row
Set display: flex on a container and its direct children stop stacking and line up along a line instead. The container is now a flex container, its children are flex items, and that one declaration is the doorway into a whole layout system built for arranging things along a line — which is most of the interface furniture you'll ever build.
Two axes, and the whole thing hangs off them
A flex container has two directions, and naming them right is what makes everything else click.
The main axis is the direction the items line up. By default that's a row, running left to right. The cross axis is the one at right angles to it — top to bottom, for a row. You pick the main axis with flex-direction: row (the default) or column, which stands the whole thing up so items stack down the cross-axis-that's-now-horizontal. For now, keep it a row.
Two properties do the arranging, one for each axis.
justify-content spreads the items along the main axis — it decides where the spare horizontal space goes. Watch the three items move as you change it, same row, same items:
justify-content: flex-start -> x [0, 80, 160] (bunched at the start)
justify-content: center -> x [180, 260, 340] (bunched in the middle)
justify-content: flex-end -> x [360, 440, 520] (bunched at the end)
justify-content: space-between -> x [0, 260, 520] (ends pinned, gaps equal)
justify-content: space-around -> x [60, 260, 460] (equal space around each)
space-between is the one that builds nav bars: first item hard against the left, last item hard against the right, and whatever's left over shared evenly in the gaps. That's a logo and a menu at opposite ends with a single property.
align-items positions the items along the cross axis — up and down, in a row. Its default is stretch, which is why a flex item with no set height grows to fill the container's height:
align-items: stretch (no item height) -> item is 120 tall (fills the cross axis)
align-items: flex-start (item h40) -> item top at 0
align-items: center (item h40) -> item top at 40 ((120-40)/2)
align-items: flex-end (item h40) -> item top at 80
The trap in the names
The obvious mental shortcut — "justify is horizontal, align is vertical" — is wrong, and it'll bite you the first time you set flex-direction: column. justify-content is along the main axis and align-items is across it. In a row the main axis happens to be horizontal, so justify looks horizontal. Flip to a column and the main axis is now vertical, so justify-content moves things up and down and align-items moves them side to side. They swap. Learn them as main-and-cross, not horizontal-and-vertical, and you never have to relearn them.
That pair also settles the oldest question in CSS — how do you centre something, really centre it, both ways? Now you can just say it: put it in a flex container, justify-content: center for the main axis, align-items: center for the cross. Dead centre, no margins-auto folklore.
Here's the seed you'll grow into a real component. A nav bar is a flex row with justify-content: space-between to push the logo and the menu apart, and align-items: center so a tall logo and short text still line up on one baseline. That's four lines and the skeleton already stands. What it can't do yet is survive a narrow screen — for that the items need to know how to grow, shrink, and wrap, which is the next thing, and the thing that trips everyone.
flex: 1 does not mean equal
Two items in a 600-pixel row, both flex: 1. That should split the row in half — 300 each. But give the first one some padding and a border, and give the second a width: 400px, and measure what actually lands.
.row { display: flex; width: 600px; }
.a { flex: 1; padding: 20px; border: 5px solid; }
.b { flex: 1; width: 400px; }
.a : width 325 flex-basis "0%" (the 400px width is nowhere)
.b : width 275 flex-basis "0%"
Not 300 and 300. Not 400 for .b either — the width: 400px might as well not be there. .a is 325 and .b is 275, and to see why that's actually correct, you have to see what flex: 1 unpacks into.
Three numbers hiding behind one
flex: 1 is shorthand for three properties:
flex: 1 == flex-grow: 1 flex-shrink: 1 flex-basis: 0%
flex-basis is the item's starting size along the main axis, before any growing or shrinking happens. flex: 1 sets it to 0% — so both items start from zero width. That's the first surprise resolved: flex-basis: 0% replaces width entirely for the sizing algorithm, which is why .b's width: 400px never gets a vote. Its computed flex-basis came back "0%", not auto, and the width is dead on arrival.
flex-grow is how greedily the item grabs a share of the leftover space — the room in the container after every item's starting size is accounted for. A grow of 1 on both means they split the leftover evenly.
flex-shrink is the mirror image: how readily the item gives space back when there isn't enough. Ignore it while everything fits; it matters in the second half of this chapter.
So here's the actual sequence the browser runs, and it's worth doing by hand once:
1. Both items start at flex-basis 0. (width:400 discarded)
2. But .a carries padding and border that are always there:
padding 20+20 + border 5+5 = 50. Its outer starting size is 50; .b's is 0.
3. Leftover space = 600 − 50 − 0 = 550.
4. Grow factors 1 : 1 -> each item takes 550 / 2 = 275 of the leftover.
5. .a content = 0 + 275 = 275, plus its 50 of decoration = 325 border box.
.b content = 0 + 275 = 275.
That's the whole thing. The two items received an exactly equal share of the free space — 275 each. They ended up different widths only because one of them is carrying 50 pixels of padding and border that the share gets added on top of. flex: 1 promises an equal share of what's left over, and it kept that promise. It just never promised equal final widths.
The model isn't a story; it predicts to the sub-pixel. Two more cases, guessed from the same five steps, then measured:
3 items flex:1, first carries 50px decoration, in 600:
leftover 550, share 550/3 = 183.33 -> measured 233.33 / 183.34 / 183.33
.p{flex:1;padding:10} + .q{flex:2}, in 600:
leftover 580, split 1:2 = 193.33 / 386.67 -> measured 213.33 / 386.67
When an item flatly refuses to shrink
Now the shrink side, and the trap that produces the most "why is my layout overflowing" bugs. Two items, both flex: 1 1 0, in a 300-pixel row. That's 150 each. But put one long unbreakable word in the first item — a title, a URL, an email — and measure.
.row { display: flex; width: 300px; }
.it { flex: 1 1 0; }
default : item1 width 185.75 item2 width 114.25
The first item planted itself at 185.75 and shoved its sibling down to 114.25. It refused to shrink below 185.75 — and 185.75 is exactly the min-content size of that word from the last chapter. That's the culprit: a flex item's min-width defaults to auto, and for a flex item, auto doesn't mean zero — it means your content-based minimum, the narrowest you can get without breaking your content. Flex-shrink can squeeze an item all the way down to that floor and no further, so a single long word becomes a wall the layout can't get past.
The release is one line — set the floor to zero yourself:
min-width: 0 on item1 : item1 width 150 item2 width 150 (but item1 scrollWidth 186 > 150)
Both are 150 now, evenly split. But look at that last number: the word is still 186 wide and the box is 150, so it's overflowing its own item — scrollWidth outruns width. The fix didn't make the word smaller; it just stopped the word from bullying the layout. You've traded a squeezed sibling for content that spills, and you finish the job the normal way — overflow: hidden with text-overflow: ellipsis, or letting that region scroll.
Note
overflow: hidden releases the floor on its own, too: making the item a scroll container sets its automatic minimum to zero. That's the same sealed-world mechanism from the margin chapter, turning up again — this time to size a flex item.
So flex: 1 gives an equal share of the leftover, never an equal width; flex-basis overrules width; and min-width: 0 is the incantation you reach for the moment a flex row overflows because one child won't yield. Keep that last one handy: it's the line you'll reach for most often when a nav bar has to survive real content.
Grid: when you need two dimensions
Flexbox lays things out along a line. That's the right tool for a row of buttons or a nav bar — but a lot of layout isn't a line, it's a table: a page with a header across the top, a sidebar down the left, and content filling the rest, all lining up. Flex can fake that by wrapping items into rows, but each wrapped row sizes its columns on its own, so column two in row one won't line up with column two in row two. When things have to align in both directions at once, that's grid's job.
Here's a container with a fixed set of columns and rows, and four items — two of them told to span across tracks. Measure where everything lands.
.grid {
display: grid;
grid-template-columns: 120px 120px 120px;
grid-template-rows: 60px 60px;
gap: 10px;
}
#wide { grid-column: 1 / 3; grid-row: 2; } /* cover columns 1 and 2, on row 2 */
#tall { grid-column: 3; grid-row: 1 / 3; } /* stay in column 3, cover both rows */
resolved columns : 120px 120px 120px
resolved rows : 60px 60px
a -> x 0 y 0 120 × 60
b -> x 130 y 0 120 × 60 (130 = 120 + 10 gap)
wide -> x 0 y 70 250 × 60 (250 = 120 + 10 + 120: two columns)
tall -> x 260 y 0 120 × 130 (130 = 60 + 10 + 60: two rows)
wide stretched across two columns and came out 250 wide. tall stretched down two rows and came out 130 tall. And nothing drifted — wide still starts at x: 0, exactly where column one starts, and tall sits at x: 260, exactly where column three starts. Every item snapped to the same shared grid, no matter how many tracks it covered. That "shared grid everything snaps to" is the thing flex doesn't have and the reason grid exists.
Lines, not boxes
The trick to thinking in grid is to stop counting cells and start counting the lines between them. Three columns are defined by four vertical lines, numbered 1 to 4 from the left. You place an item by naming the line it starts on and the line it ends on.
column line: 1 2 3 4
| | | |
row line 1 ─┼────────┼────────┼────────┼
│ a │ b │ │
│ │ │ tall │ grid-column: 3
row line 2 ─┼────────┴────────┤ (1/3) │ grid-row: 1 / 3
│ wide │ │
│ (cols 1 / 3) │ │
row line 3 ─┼─────────────────┴────────┼
So grid-column: 1 / 3 reads as "from vertical line 1 to vertical line 3", which covers the two columns in between. grid-row: 1 / 3 does the same downward. Once you see the lines, placement stops being guesswork — you're just naming corners.
Items you don't place explicitly flow into the leftover cells in source order, which is why a and b landed in the first two cells without you saying so. You place the few things that need placing and let the rest fall into line.
Grid or flex?
They're not rivals so much as tools for different shapes, and the choice is usually obvious once you name the shape.
Reach for flex when you're arranging things along a single line and want the content to drive the sizes: a nav bar, a toolbar, a row of tags, a stack of form fields. One axis, content-sized, wraps easily.
Reach for grid when the structure is two-dimensional and things must align across both axes: a page shell (header, sidebar, main, footer), a dashboard of panels, a card layout where every card's title, body, and button line up row to row. You define the skeleton on the container and slot children into it.
And they nest without any fuss — the way real interfaces are built. A grid defines the page's big regions; inside the header region, a flex row arranges the logo and the menu; inside a card, another flex column stacks the title over the text. You don't pick one for the whole app. You pick the one that matches the shape of the thing in front of you, at the level you're working.
One piece is still missing. Every track here was a hard 120px, which is fine for a diagram and useless for a real screen that changes width. The property that makes grid tracks share out space the way flex: 1 shares out a row — and the responsive trick that falls out of it for free — is next.
fr: a share of what's left
Grid tracks don't have to be fixed pixels. The unit built for them is fr, and it behaves like flex: 1 did — it hands out space that's left over. The trap is in those last three words, so start with a template that has a fixed column, two flexible ones, and a gap between them all, and predict the widths before you read them.
.grid {
display: grid;
grid-template-columns: 200px 1fr 2fr;
gap: 20px;
width: 800px;
}
tracks -> 200px 186.656px 373.344px
If you guessed the 1fr and 2fr would split the remaining 600 into 200 and 400, you'd be close but wrong, and the gap between right and wrong is exactly where people lose an afternoon. fr is a share of the leftover space, and the leftover is computed after the fixed tracks and after the gaps are taken out:
leftover = 800 − 200 (fixed column) − 40 (two 20px gaps) = 560
1fr = 560 / 3 = 186.67 2fr = 373.33
That's the answer, to the sub-pixel. The gaps came out of the pot before the fractions were shared, which is the piece people miss when they write 1fr 1fr and wonder why each column isn't exactly half the container. It isn't half the container — it's half of what's left after the gutters.
The 1fr blowout
Now the same trap flexbox had, wearing grid's clothes. Two columns, 1fr 1fr, in a 300-pixel grid. That's 150 each. Put a long unbreakable word in the first cell and measure.
.grid { display: grid; grid-template-columns: 1fr 1fr; width: 300px; }
1fr 1fr -> 185.75px 114.25px <- not 150/150
minmax(0, 1fr) minmax(0, 1fr) -> 150px 150px (fix in the template)
1fr 1fr, overflow:hidden on item -> 150px 150px (fix on the item)
There's that 185.75 again — the min-content width of the word, back for a third time. It happens because 1fr on its own is secretly minmax(auto, 1fr): the track can grow to its fair share, but its floor is auto, which for a grid item is the content-based minimum. So the first track refuses to shrink below the word and steals the difference from its neighbour, exactly like the flex item did.
And the two fixes are the same two you already have. minmax(0, 1fr) sets the track's floor to zero in the template. Or overflow: hidden on the item makes it a scroll container and zeroes its automatic minimum — the formatting-context trick from way back, now cleaning up after grid too. One mechanism keeps paying rent across every layout system, which is the whole reason it was worth learning as a mechanism instead of a set of tips.
The responsive part, for free
Here's what makes fr more than a tidier way to write percentages. Combine it with repeat() and a keyword that means "as many as fit", and a grid rearranges itself as its container changes width — with no media query anywhere.
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
@ 640px -> 4 columns, each 152.5px
@ 400px -> 2 columns, each 195px
@ 300px -> 1 column, each 300px
Read repeat(auto-fit, minmax(150px, 1fr)) as one instruction: "make columns that are at least 150 wide but grow to share the leftover, and fit as many as you can across the width you have." At 640 it fits four, at 400 only two, at 300 just one — and the cards restack themselves without you writing a single breakpoint. This is the first taste of the guide's later claim that the best media query is often no media query, and it falls straight out of two ideas you already have: fr sharing leftover, and minmax setting a floor.
One footnote you'll trip on if you skip it: auto-fit has a near-twin, auto-fill, and they differ only when you have fewer items than would fit. Put two cards in a 700-pixel track:
auto-fit -> tracks "345px 345px 0px 0px" (empty tracks collapse; cards stretch)
auto-fill -> tracks "167.5px ×4" (empty tracks kept; cards stay narrow)
auto-fit collapses the columns nothing landed in, so your two cards stretch to fill the row. auto-fill keeps the empty columns reserved, so your cards stay their minimum width with blank space to the right. For a card grid you almost always want auto-fit — fill the space you've got. Now you have grid's two axes and tracks that flex and reflow, which is enough to build most of a page's skeleton. The remaining question is what happens when a box wants to leave the flow entirely, which is the next part.
Out of flow: absolute, fixed, sticky
You put a little close button in the corner of a card: position: absolute; top: 0; right: 0. It jumps to the corner of the whole page instead of the card. Or you build a modal with position: fixed to cover the screen, and it lands halfway down the page rather than over everything. Both bugs are the same missing idea, and it's the last big one in the guide: an out-of-flow box is placed against something, and you have to know what.
Leaving the flow
Everything up to now has been in flow: each box takes up space and pushes its neighbours around. position: absolute and position: fixed pull a box out of flow. It stops reserving space — its siblings lay out as if it were deleted — and instead of flowing, you pin it with top, right, bottom, left.
The catch is what those offsets are measured from. top: 0 never means "the top of the page". It means "the top of my containing block" — the rectangle this particular box is positioned against. So the entire question, every time a positioned box lands somewhere strange, is: what is its containing block? The answer is different for absolute and fixed, and the difference is where the bugs live.
absolute: the nearest anchor above it
Drop an absolutely positioned child, top: 10px; left: 10px, into ancestors that differ only in one property, and watch where it lands.
.child { position: absolute; top: 10px; left: 10px; }
/* ancestor is one of: position:static | position:relative | transform:translateZ(0) */
ancestor position:static -> child at (10,10) against the VIEWPORT
ancestor position:relative -> child at (45,155) against the ancestor's PADDING box
ancestor transform:... -> child at (45,265) against the ancestor's PADDING box
With a plain static ancestor, the child ignored it entirely and measured from the viewport — landing at (10, 10), ten pixels from the page corner. That's the close-button bug: the card wasn't a containing block, so the button anchored to the page.
Give the ancestor position: relative and it becomes the anchor: the child lands at the ancestor's padding box plus the offset. That's the whole point of the relative/absolute pairing everyone learns — position: relative on a parent is you saying "measure my absolute children against me." (Note which edge: it's the padding box, inside the border, not the content box or the outer edge.)
Now the part that isn't in the folklore. position: relative isn't the only thing that makes an ancestor an anchor. Measure all the triggers:
position:relative -> ANCHORED
transform:translateZ(0) -> ANCHORED
will-change:transform -> ANCHORED
contain:paint -> ANCHORED
filter:blur(0px) -> ANCHORED
A transform anchors it. So does will-change, contain, and filter. Which means a parent that got transform: translateZ(0) for a scroll-performance reason two years ago silently becomes the anchor for every absolute child beneath it, with no position: relative in sight. When "why is this absolutely positioned thing off by a bit" won't resolve, one of these on an ancestor is usually why.
fixed: the viewport, until it isn't
position: fixed is the same idea, but its containing block is normally the viewport — which is exactly why a fixed header stays put while the page scrolls under it. Except "normally" is carrying weight. Run the same ancestor test with a fixed child:
ancestor position:relative -> fixed at (0,0) stayed on the VIEWPORT
ancestor transform:translateX(0) -> fixed at (50,220) captured by the ANCESTOR
ancestor will-change:transform -> captured
ancestor contain:paint -> captured
ancestor filter:blur(0px) -> captured
Here's the asymmetry that makes the modal bug so hard to spot. For fixed, position: relative does nothing — the fixed child stayed on the viewport at (0,0). But transform, will-change, contain, and filter all capture it, dragging it 220 pixels down onto the ancestor. So the two triggers lists differ by exactly one entry:
| Ancestor has… | anchors absolute? |
anchors fixed? |
|---|---|---|
| nothing (static) | no → viewport | no → viewport |
position: relative |
yes | no |
transform / will-change / contain / filter |
yes | yes |
That single differing cell is the most common "my full-screen modal is in the wrong place" cause in real apps. The modal's own CSS is perfect. Somewhere far above it, an animation library or a will-change hint put a transform on an ancestor, and position: fixed quietly stopped meaning "the viewport". You cannot see it from the element; you find it by walking up the ancestors.
sticky: in flow until it isn't
position: sticky is the hybrid. The box stays in normal flow, taking up space and scrolling with the page like everything else — until it hits the threshold you set (say top: 0), at which point it sticks to that edge and stays there while the rest scrolls past. It's positioned relative to its scroll container, which makes it perfect for section headers and table headings. It has its own way of quietly breaking, and the culprit is an ancestor you wouldn't suspect. That scroll container is just the nearest ancestor that actually scrolls, and any ancestor with overflow: hidden, auto, or scroll becomes one (the same overflow that sealed off a formatting context back in chapter 3, wearing a different hat). So when a sticky header won't stick, it's usually because a wrapper above it quietly has overflow set: the header is dutifully sticking inside that box instead of against the page. Same move as everything else in this part — don't touch the sticky element, walk up its ancestors.
So the move, whenever a positioned box is in the wrong place, is not to nudge the numbers. It's to walk up its ancestors and ask which one is its containing block — because that's the box the coordinates were relative to all along. Get that box right and the offsets you already wrote suddenly make sense.
Why z-index doesn't work
Your dropdown is behind the header. You set z-index: 9999. It's still behind the header. You try 999999. Still behind. The number is clearly not the problem, and no bigger number is going to become the problem's solution.
Here's the actual culprit, and it's nowhere near the dropdown:
.card { transform: translateY(0); } /* left over from an animation nobody's touched */
.dropdown { z-index: 9999; }
That transform on the card is what's holding the dropdown down, and it probably went in for a hover effect months ago. To see why a property that says nothing about layering decides the layering, you need the one idea this chapter is about.
Some properties open a new world
Most properties change how an element looks. A few do something extra and invisible: they make the element the root of a new stacking context — a self-contained little world of front-to-back ordering. And here's the part that bites: every z-index inside that world is measured against the other things in the same world, not against the page. The whole world then takes its place, as a single unit, in its parent's ordering.
So z-index: 9999 on your dropdown is real. It just means "frontmost among my siblings, inside this card." And the card, as a whole, sits behind the header. There is no number you can type on the dropdown that climbs out of the card, because the number was never competing with the header in the first place — it was competing with its siblings inside the card.
Watch it happen with two numbers that couldn't be more lopsided. A child with z-index: 9999 sits inside an ancestor; a sibling with z-index: 1 overlaps it from outside. Hit-test the overlapping point to see which one is actually on top.
.ancestor { position: relative; /* + one test property */ }
.child { position: absolute; z-index: 9999; } /* inside .ancestor */
.sibling { position: absolute; z-index: 1; } /* outside, overlapping */
ancestor: (nothing) -> child (9999) wins 9999 really is on top
ancestor: transform:translateZ(0)-> sibling (1) wins 9999 is trapped inside
Same two elements, same 9999 versus 1, and adding a single transform to the ancestor flips the winner. The 1 beats the 9999, because the 9999 is now sealed inside a world that, as a whole, sits behind the sibling. This is the dropdown-behind-header bug in miniature, and it's diagnosable only by looking at an ancestor that mentions neither element's z-index.
What opens one, and the traps in the list
The triggers aren't arbitrary, but a couple of them are genuinely surprising. Here's the measured list — same probe, many ancestors:
| Property on the ancestor | New stacking context? |
|---|---|
opacity: 1 |
no |
opacity: 0.99 |
yes |
transform: none |
no |
transform: translateZ(0) |
yes |
position: relative; z-index: auto |
no |
position: relative; z-index: 0 |
yes |
will-change: transform, filter, isolation: isolate |
yes |
container-type: inline-size |
no |
Two rows are worth staring at. opacity: 1 creates nothing; opacity: 0.99 creates a stacking context. The trigger is the computed value, not the presence of the property — which means a fade-in animation, sweeping opacity from 0 up toward 1, creates a stacking context for exactly as long as the fade runs, and can shove a dropdown behind something for the length of an animation and then release it. "It flickers behind the header only while it's opening" is this, precisely.
And position: relative on its own does nothing to stacking — but the moment you add a z-index to it, even z-index: 0, it becomes a context root. That's usually fine, and occasionally it's the thing that traps a child you didn't want trapped.
Two entries earn a note the other way. isolation: isolate is the deliberate one — it creates a stacking context and does nothing visual, so it's the clean tool when you actually want to seal a subtree's z-ordering on purpose. And container-type: inline-size, the responsive property from later, does not create one — it changes layout and formatting context, but it leaves stacking alone, which is exactly what you want from it.
The rule, and the move
Strip it to one line: z-index only compares values within a single stacking context. Across contexts, it doesn't compare at all. A z-index: 5 in one context and a z-index: 500 in another never race each other — their contexts race, and the loser's whole subtree goes behind the winner's, five hundred and all.
Which makes the debugging move the same shape as the last chapter's. When z-index "isn't working," stop editing the number. Walk up the ancestors of the thing that's stuck, and find the nearest one with a stacking-context trigger — a transform, an opacity below 1, a filter, a stray z-index. That ancestor is the ceiling your element can't rise above. Your two options are the ones from the top of the page: move the element out of that ancestor entirely, or stop the ancestor from creating the context. "Move it out" is why every serious dropdown ends up rendered at the end of <body> instead of next to the button that opened it — which is a problem you'll solve for real when you build the menu.
Responsive by default
The old way to make a heading smaller on a phone is a media query: pick a width, and below it, swap the font size. Here's a way to make it scale smoothly with the screen — bigger as the window grows, but never smaller than readable and never cartoonishly large — in one line, no breakpoint anywhere.
h1 { font-size: clamp(16px, 12px + 1.5vw, 28px); }
320px -> 16.8px (preferred 12 + 1.5vw = 16.8, inside range)
768px -> 23.52px (preferred = 23.5, inside range)
1200px -> 28px (preferred = 30, capped at 28)
1600px -> 28px (preferred = 36, still capped)
The heading grew from 16.8 to 23.5 as the screen widened, then flattened out at 28 and refused to get bigger. No breakpoint decided any of that. The size was computed from the actual width at every step, which is a different way of thinking about "responsive" — and the better default.
Three functions that pick a number for you
The vw unit is the new piece: 1vw is 1% of the viewport's width, so 1.5vw is a length that grows as the window grows. On its own that's dangerous — at 3000px wide, 1.5vw is 45px, a heading the size of a fist. That's what the other two parts of clamp() are guarding against, and they're functions in their own right.
min(a, b) resolves to the smaller of its arguments. It reads backwards at first — min gives you the smaller — but it's exactly what you want for a cap. width: min(600px, 100%) means "600px, unless that's more than the container, in which case fill the container":
viewport 400 -> 400 (100% is smaller, so it fills)
viewport 800 -> 600 (600px is smaller, so it caps)
viewport 1200 -> 600
That single declaration is a content column that's 600px wide on a laptop and edge-to-edge on a phone, with no media query and no overflow — the narrow case is handled by the same rule as the wide one.
max(a, b) is the mirror: the larger of the two, which you use to set a floor. max(16px, 5%) never goes below 16px but grows past it when 5% is bigger.
clamp(low, preferred, high) is those two stacked: a floor, an ideal, and a ceiling. It's literally max(low, min(preferred, high)) — take the preferred value, but never below low, never above high. The preferred part is usually a fixed amount plus a vw amount so it tracks the screen, and the floor and ceiling keep it sane at the extremes. That's the shape the heading traced:
You can clamp anything with a length: padding that grows with the page but stops at a maximum, a gap, a column width. padding-inline: clamp(16px, 5%, 48px) gives you gutters that are 16px on a phone, scale up through the middle, and top out at 48px on a wide monitor — measured at 16, 35, and 48 across three widths.
The mindset, which is the actual lesson
Step back from the functions. The shift they're pushing you toward is this: instead of detecting the screen and switching layouts at chosen widths, you tell each piece how to size itself against its content and the space it's given, and let it adapt continuously. A media query is a discrete jump — it looks right at the widths you tested and can look wrong in the gaps between them, because it's sorting screens into a few buckets. Content-and-space-driven sizing is right at every width, because it's computed from the width you actually have.
You already have most of the toolkit. fit-content and the intrinsic sizes size things to their content. The auto-fit grid reflows columns to fill the space. And min, max, clamp scale the in-between values fluidly. Put those together and a genuinely large amount of layout responds without a single @media rule. That's not a party trick — it's fewer breakpoints to maintain and fewer awkward widths where the design falls between two buckets.
So the working rule is: reach for a breakpoint only when the layout has to change shape, not merely change size. Making a heading smaller, a column narrower, gutters tighter — that's sizing, and sizing is what these functions are for. Genuinely rearranging things — dropping a sidebar below the content, collapsing a nav into a menu — is a shape change, and that's the one case where you do want the tool in the next chapter.
When you do need a breakpoint
Sometimes scaling isn't enough and the layout has to genuinely rearrange. Before the tools for that, though, a trap that catches everyone the first time they try to fill a phone screen — because it's really a lesson about what "the viewport" even means.
The 100vh trap
You want a hero section that fills the screen, so you write height: 100vh. On your laptop it's perfect. On a phone the bottom gets cut off, or the whole thing jerks taller when you scroll and the address bar slides away. The section is the wrong height, and it keeps changing height.
The unit is the clue. 1vh is 1% of the viewport height — but on a phone the viewport height has a moving definition, because the browser's address bar expands and retracts as you scroll. So "100% of the viewport" has to mean one of three things, and CSS gives you a unit for each:
lvh(large) assumes the browser chrome is retracted — the tallest the viewport ever gets.100lvhis taller than what you can see while the address bar is showing, so it clips at the bottom.svh(small) assumes the chrome is expanded — the shortest the viewport gets, which is the height you're guaranteed to see no matter what.100svhalways fits.dvh(dynamic) tracks the chrome live. It's exactly right at every instant — and it's unstable on purpose: the value changes as you scroll, so content sized indvhreflows while the user is scrolling it.
Plain vh is the old unit and behaves like lvh — which is why 100vh clips. The fix for "fill the screen and never get clipped" is 100svh; reach for dvh only when you truly want to track the chrome and can accept the reflow.
Note
This is one claim in the guide the lab can't show you. Headless Chromium has no expanding chrome, so it reports every family equal — measured, vh = svh = lvh = dvh = 800 at an 800px-tall viewport. The difference is real on a phone; the source here is the CSS spec, not a measurement. Everything else in this guide you can reproduce yourself; this one you'll have to see on a real device.
Media queries change the page's shape
When the layout has to rearrange — a two-column spread becoming one column, a horizontal nav becoming a stack — that's a media query. It's a block of CSS gated on the window:
.layout { display: grid; grid-template-columns: 250px 1fr; }
@media (max-width: 768px) {
.layout { grid-template-columns: 1fr; } /* sidebar drops below the content */
}
That's the right tool for the page's big shape changes. But it has a blind spot, and it's a bad one for the way you actually build things now.
A media query asks the viewport — the whole window. A component doesn't live in the whole window; it lives in a slot. A card might be in a wide main column on one page and a narrow sidebar on another. Ask the viewport "are we wide?" and on a big screen the answer is yes everywhere — so the card in the skinny sidebar gets the wide, side-by-side layout and overflows its little slot. The card asked the wrong thing about the wrong box.
Container queries ask the parent
The fix is to let the component respond to the space it's actually in. Mark an ancestor as a container, then write the query against that:
.panel { container-type: inline-size; } /* "measure me, I'm a container" */
.card { display: flex; flex-direction: column; } /* stacked by default */
@container (min-width: 400px) {
.card { flex-direction: row; } /* side-by-side, if the CONTAINER is wide */
}
Now drop the identical .card into two containers of different widths and measure — same class, same markup, different result:
.card in a 300px container -> flex-direction column, children stacked (y 0, 44)
.card in a 600px container -> flex-direction row, children side-by-side (x 0, 44)
Same card, and its container's width alone decided whether it stacked or spread — the window was 1000px wide in both cases and had no say. That's why container queries are the right default for anything reusable: a component built this way adapts correctly wherever you drop it, because it's asking about its own slot instead of guessing from the window. The nav bar you're about to build leans on exactly this.
Two footnotes tie back to earlier chapters. container-type: inline-size turns the panel into an independent formatting context — the same sealed world from the margin-collapse chapter — so a container also contains its children's floats and margins, which is usually a bonus and occasionally a surprise. And, as you measured two chapters ago, container-type does not create a stacking context, so making something a container won't quietly trap its z-index.
So the ladder is: size things intrinsically first, with clamp, fit-content, and auto-fit, and most layouts never need more. Reach for a media query when the page changes shape at a width. Reach for a container query when a component has to adapt to whatever slot it's dropped into. You've now got every tool the browser gives you for "where do the boxes go" — so the rest of the guide spends its measurements building two real things with them.
Build: a nav bar that survives a phone
This is the thing you came for, and you now have every part of it. A nav bar with a brand on the left, links and a button on the right, that collapses to a menu button when it's squeezed — built from parts you've already measured, not copied from anywhere. Here's the whole thing, then the walk through why each line is there.
<div class="wrap">
<header class="nav">
<a class="brand">Planur</a>
<nav class="links"><a>Dashboard</a><a>Goals</a><a>Calendar</a><a>Settings</a></nav>
<button class="cta">New goal</button>
<button class="menu">Menu</button>
</header>
</div>
.wrap { container-type: inline-size; } /* respond to my slot, not the window */
.nav { display: flex; align-items: center; gap: 16px; padding: 12px 20px; }
.links { display: flex; gap: 16px; margin-left: auto; min-width: 0; }
.menu { display: none; margin-left: auto; }
@container (max-width: 560px) {
.links, .cta { display: none; }
.menu { display: inline-block; }
}
Reading it back through the guide
display: flex; align-items: center makes the header a row with everything centred on one line across the cross axis — the flexbox from part four. It matters here because the brand is bigger type than the links, so their boxes are different heights. Measure it wide and everything still shares a centre line:
nav height 57
brand x 21 vertical centre 29.5
links x 484 vertical centre 29.5
Settings x 718 vertical centre 29.5 (last link)
CTA x 793 vertical centre 29.5
menu display:none
Every element reports the same vertical centre, 29.5, regardless of its own height — that's align-items: center doing the one job that used to need a pile of vertical-align folklore.
margin-left: auto on the links is the trick worth pocketing. A leftover margin set to auto in a flex row swallows all the free space on that side, so it shoves the links and the button hard to the right and leaves the brand alone on the left. It's justify-content: space-between for when you want one specific gap to absorb the slack instead of spreading it everywhere. The links start at 484 and the button sits out at 793, while the brand stays at 21.
min-width: 0 on the links is the one-line fix from the flexbox chapter, here as insurance: if the link labels ever got long enough to overflow, this is what lets the row shrink them instead of blowing out of the header. You paid for that line already; this is where it earns its keep.
The collapse
The @container block is the shape change. Below 560px of container width, the links and the button hide and the menu button takes their place. Measure the same nav in a narrow slot:
nav height 57
brand x 21 vertical centre 29.5
links display:none
CTA display:none
menu x 340 vertical centre 29.5 (visible, pushed right)
Same nav, same 57px height, and the whole right-hand cluster swapped for a single menu button pinned to the right by the same margin-left: auto. Nothing about the structure moved; only the contents did.
And notice what the breakpoint is measured against. This collapses at 560px of the container's width, not the window's — that's the container query from the last chapter, and it's the difference between a nav bar that only works as a full-width page header and one you can also drop into a 400px sidebar and have it collapse correctly there too. Ask the viewport and the sidebar copy would try to show all four links in 400px and overflow. Ask the container and it just works wherever it lands.
wide (900): [ Planur ......................... Dashboard Goals Calendar Settings [New goal] ]
narrow(420): [ Planur ....................................................... [Menu] ]
That's a real, responsive nav bar with no framework and no JavaScript — flexbox for the row, an auto margin for the split, min-width: 0 for safety, and a container query for the collapse. The one thing it doesn't do yet is open anything: the menu button appears, but tapping it should drop a panel of the hidden links into view, and that panel has to position itself against the button and float above everything else. Positioning a thing against another thing, above everything else, is the last build — and it's where the containing-block and stacking-context chapters cash out together.
Build: a menu that positions itself
The menu button from the last chapter needs to do something: drop a panel of links right below itself. The interesting question is how the panel knows where "below itself" is — and this is where the containing-block and stacking-context chapters both come due at once.
Anchoring the panel to the button
Wrap the button in a position: relative box, and make the menu position: absolute with top: 100%. That's the whole positioning trick.
.field { position: relative; display: inline-block; } /* the anchor; hugs the button */
.menu { position: absolute; top: 100%; left: 0; } /* drop below the button */
button height 36
menu top (from field) 36 -> exactly the button's bottom edge
menu left (from field) 0
sits directly under button: true
The menu landed 36px down — precisely the button's height — so it sits flush under it. Two chapters are doing the work here. The position: relative on .field makes it the menu's containing block, so top and left are measured from the field, not the page — that's the whole "positioned against what?" lesson, used on purpose this time instead of by accident. And top: 100% reads as "100% of the containing block's height", and because the field is inline-block it hugs the button, so its height is the button's height. The menu tracks the button wherever the button goes. No JavaScript measured anything; the layout math placed it.
For a menu sitting in open space, that's the finished component. The trouble starts when it isn't in open space.
The overflow clip that bites
Drop that same button-and-menu inside a card that has overflow: hidden — which cards very often do, to keep content inside their rounded corners. Watch what happens to a 120px-tall menu.
.card { overflow: hidden; } /* for the rounded corners */
ancestor overflow:hidden -> menu is 120 tall, but only 11 visible
ancestor overflow:visible -> menu is 120 tall, all 120 visible
The menu is positioned perfectly — it's a full 120 tall and exactly where you asked. But overflow: hidden on the card sliced it down to an 11-pixel sliver, because anything crossing the card's edge gets clipped. This is the same overflow: hidden from the margin chapter and the grid chapter — the one that establishes a scroll container — showing its other face. There it contained floats and killed the 1fr blowout, which you wanted. Here it clips your menu, which you very much don't. One mechanism, and whether it helps or hurts depends only on what's crossing the edge.
And clipping isn't the only ancestor that can ruin a correctly-placed menu. Remember the stacking-context chapter: even if nothing clips it, an ancestor with a transform or an opacity below 1 traps the menu's z-index, and it opens behind the next card instead of over it. So there are two different, unrelated ancestors that each wreck a menu that is doing everything right — one clips it, one buries it — and neither is visible from the menu's own CSS.
Why the frameworks all do the same thing
Here's the ceiling, stated plainly: you cannot out-CSS an ancestor that clips or traps you. There's no overflow value on the menu that escapes a parent's overflow: hidden, just as there was no z-index that escaped a parent's stacking context. The clipping and the trapping both happen up the tree, and the menu is downstream of both.
So the only real fix is to get the menu out of that ancestor entirely — render it somewhere near the end of <body>, where nothing clips it and nothing traps it, and then position it against the button with JavaScript that measures where the button is. That relocation is called a portal, and it's exactly what Radix and Base UI — the libraries already in your stack — do for every dropdown, popover, and tooltip. Now you know the two measured reasons they bother: an ancestor's overflow would clip the panel, and an ancestor's stacking context would bury it.
That's the honest boundary of a pure-CSS menu. It self-positions beautifully as long as it lives in open space or a plain container — which covers a lot of real menus, and is worth reaching for before you pull in a library. The moment it has to escape an overflow: hidden card or a transformed ancestor, you've hit the wall, and jumping the menu out of the tree is the move. Measuring the button's real position to place the portalled menu — reading the DOM's geometry back in JavaScript — is where the next guide picks up.
When it still won't move
You've changed the number three times and the box hasn't budged. This is the situation the whole guide was for, so here's the procedure — the one you run on the layouts this guide never showed you.
The first move is a mindset one: stop editing the element that looks wrong. Almost every layout surprise in this guide came from somewhere else — a parent's overflow, an ancestor's transform, a container with no height. The box you're staring at is usually the victim, not the cause. So open the browser's element inspector, select the misbehaving box, and read what the browser actually computed rather than what you typed — the Computed panel gives you the real border-box size, and the layout overlay draws the flex and grid lines. Then walk the questions.
The procedure
the box is the WRONG SIZE
├─ a % height computing to 0? the parent has no definite height (ch4) → up
├─ an inline box ignoring w/h? it's inline; make it inline-block/block (ch2) → self
├─ 450 when you set 400? box-sizing is content-box (ch1) → self
└─ a flex/grid item, odd width? it got the container's share, not its w (ch7,9)→ parent
it OVERFLOWS or WON'T SHRINK
└─ a flex or grid item? min-width:auto floors it at content size;
try min-width:0 (or minmax(0,1fr)) (ch7,9)→ self
a MARGIN or GAP is wrong
├─ a vertical margin that escaped? margins collapsed through a boundary (ch3) → parent
└─ two margins gave the max? adjoining margins collapse, not sum (ch3) → self
a POSITIONED box is in the WRONG PLACE
└─ what is its containing block? walk ancestors for position / transform /
will-change / contain / filter (ch10) → up
(relative anchors absolute, NOT fixed)
z-INDEX does nothing
└─ which ancestor made a stacking context? transform / opacity<1 / filter /
isolation / a z-index (ch11) → up
(z-index only compares within one context)
it's CLIPPED, or sticky STOPPED sticking
└─ an ancestor overflow:hidden|auto — a scroll container (ch3,10,15)→ up
Notice how often the right-hand column says up. That's the pattern. When the fix isn't on the element, the answer is up the ancestor chain, and the inspector lets you click straight up it.
The three questions underneath all of it
Every branch above is really one of three questions about the box, and if you can answer these you can diagnose a layout you've never seen:
-
What is this box's containing block? The rectangle its size and position are measured against. For anything positioned, this is the question — and the answer is often an ancestor you didn't expect, because
transformand friends quietly become one. -
What formatting context is it in? Is it in normal flow, or a flex or grid or block formatting context? That decides which layout algorithm sizes and places it — a flex item plays by different rules than a block, and a box inside a
overflow: hiddenancestor lives in a sealed world that won't leak margins or floats. -
What has an ancestor collapsed, clipped, or trapped? A margin that escaped, a float that wasn't contained, a menu that got clipped, a
z-indexthat got trapped — all of these are done to the box by something above it.
What to hold onto
Strip the whole guide to its frame and it's four sentences. Every element the browser draws is a box, and layout is nothing but where those boxes go and how big they get. Each box is sized and positioned relative to its containing block, and laid out by its formatting context — so those two are the first things to pin down about any box. A handful of properties — overflow, transform, opacity, position, contain — do a second, invisible job beyond how they look, quietly creating containing blocks, formatting contexts, and stacking contexts, and that second job is behind most of the surprises. And when a box won't do what you tell it, the cause is usually not on the box but on an ancestor.
You started this guide able to write CSS and hope. The difference now is that when a layout fights you, you don't reach for a bigger number — you ask what box you're really looking at, what it's measured against, and what the tree above it has done to it. That's the whole skill, and it's the same three questions every time.
Further reading
Everything above was measured rather than quoted. When you want the normative rule behind a behaviour, these are the primary sources — read them against what you just saw, not instead of it.
- MDN Web Docs — CSS — the readable companion to this whole guide; each property page has a live example and the edge cases.
- CSS Display Module Level 3 — the box tree,
display, and where "formatting context" is actually defined. - CSS Box Sizing Module Level 3 — definite vs indefinite, and
min-content/max-content/fit-contentin full. - CSS Flexible Box Layout Module Level 1 — the flex algorithm, including the automatic minimum size behind
min-width: auto. - CSS Grid Layout Module Level 2 — track sizing,
fr,minmax(), andauto-fit/auto-fill. - CSS Positioned Layout Module Level 3 —
absolute/fixed/sticky, containing blocks, and howz-indexis scoped. - CSS Containment Module Level 3 —
container-typeand@container, the parent-not-viewport query. - web.dev — Learn CSS — Google's course-length walk through the same model, with browser-support notes kept current.