CSS Layout
Why that box is 244px wide, why the flex item overflows and why z-index: 9999 still loses: the box model, normal flow, flexbox, grid, positioning and stacking contexts, measured live in your browser.
The 30-second version
Two bugs every frontend developer meets in their first year: "I set z-index: 9999 and it's still behind the header" and "why is this flex item sticking out of its container?" Both feel random. Neither is. Each follows from a rule you can learn in a few minutes.
The same four boxes, arranged by five different rules. Nothing about the boxes changes — only the CSS on the room:
.room { } /* nothing set: "normal flow" */
.item { display: block; } /* each block starts on a new line */The code you write is just the CSS shown under the demo. The browser does the rest in a step called layout: it walks the page and works out the exact size and position of every box. Browser Rendering covers where that step sits in the pipeline from HTML to pixels; this topic is about the rules it follows.
Sizes come from rules
box-sizing, its parent's layout mode and its content. Learn the rules and "why is this 244px?" becomes arithmetic.The parent decides
display: flex or grid goes on the container. The children are arranged by it; they can only nudge with properties like flex-grow or grid-column.z-index is local
z-index only competes with siblings in the same stacking context. 9999 inside a context that sits at level 1 still loses to a 2 outside it.The box model
box-sizing decides which of them width measures.From the inside out, using our furniture box:
| Layer | Furniture | What it is | Painted? |
|---|---|---|---|
| content | the sofa itself | text, images, child elements | yes |
| padding | bubble wrap | space between content and border; shows the element's background | yes (background) |
| border | the cardboard | a line around the padding | yes |
| margin | elbow room | empty space outside the border that pushes neighbours away | no, always transparent |
Sizes in this topic are in CSS pixels (px). Try the inspector: change the sliders, then flip box-sizing.
content-box (the default), width sizes only the content. The visible box is 200 + 2×20 + 2×6 = 252px wide. Browser says: visible box …px, content …px. Margin adds 16px outside on each side, so the box takes up 284px of the row.box-sizing: what does width mean?
With the default, box-sizing: content-box, width: 200px sizes only the content. Padding and border are added on top: 200 + 2×20 + 2×6 = 252px on screen. That's why a width: 100% input with some padding sticks out of its form: it's 100% plus the padding.
With box-sizing: border-box, width: 200px is the size of the visible box: content shrinks to make room for padding and border. Almost every modern codebase turns this on everywhere, with a three-line reset:
*, *::before, *::after {
box-sizing: border-box; /* ← width now means "the box you can see" */
}Margin collapsing: 30 + 20 = 30
Margins have one famous quirk. In Normal flow (the default layout, §03), when two vertical margins touch, they don't add up. They overlap, and the bigger one wins. This is Margin collapsing.
It's on purpose: paragraphs get a margin above and below from the browser's default styles, and you want one paragraph's worth of gap between two of them, not two. But it also bites you, because a child's top margin can leak out of its parent:
The rule, in full:
- Only vertical margins (top/bottom) collapse. Left and right margins never do.
- Only in normal flow. Children of a flex or grid container never collapse, which is one reason
gap(§04) is nicer than margins. - A parent's margin and its first child's margin collapse when nothing separates them: no border, no padding, no inline content between them, and the parent doesn't start its own layout region.
That last idea has a name: a Block formatting context, a sealed-off region of normal flow. display: flow-root creates one and exists exactly for this: "make this element a fresh layout root, change nothing else".
Normal flow: block and inline
That default is called Normal flow. Every element starts in it, and you leave it only by choosing another layout mode on the parent (flex, grid) or on the element itself (positioning, §07).
There are two kinds of box in normal flow:
- A Block-level box (
div,p,h1,ul,section) starts on a new line and stretches across the whole width of its parent. Like a shelf: one per row. - An Inline box (
span,a,strong,em) sits inside a line of text, like a word. When the line is full, the next word wraps.
Two common elements sit in the line too but behave more like boxes: img (and video, input) are replaced elements, whose content comes from outside CSS, and button is inline-block by default. Both do honour width and height — so <img width="200"> works even though the image flows like a word.
The display property switches an element between them. Change it below and watch which of width, height, padding and margin the browser actually honours:
.el {
display: inline;
box-sizing: border-box; /* the §02 reset: width includes padding + border */
width: 150px; height: 56px;
padding: 6px 10px; margin: 6px 8px;
border: 1.5px solid;
}width and height are ignored — the size comes from the text. Horizontal padding and margin push the neighbours aside; vertical padding is painted but doesn't push the lines apart (see how it overlaps them).The display values you'll use
display: | Joins the outside as… | Lays out its own children with… |
|---|---|---|
block | a block (new line, full width) | normal flow |
inline | a word in a line | (inline content only) |
inline-block | a word in a line, but sized like a block | normal flow |
flex / inline-flex | a block / an inline box | flexbox (§04) |
grid / inline-grid | a block / an inline box | grid (§06) |
flow-root | a block | normal flow, as a fresh layout region (§02) |
none | — not rendered at all, takes no space | — |
contents | the element's box disappears; its children act as children of its parent | — |
Notice that display answers two questions: how the element behaves among its siblings (the "outside"), and how it arranges its own children (the "inside"). inline-flex is "a word on the outside, flexbox on the inside".
Width fills, height hugs
A block's width: auto (the default) means "fill the parent". Its height: auto means "as tall as my content". That asymmetry explains a classic surprise:
.parent { } /* height: auto — as tall as its content */
.child { height: 100%; } /* 100% of… what? */A percentage height needs a parent whose height is known before the child is laid out. Here the parent's height depends on the child, so the browser gives up and treats 100% as auto. Measured in Chromium: the child is 18px tall (one line of text), not the full page. Fixes: give the parent a real height (height: 300px), or, for "fill the rest of the screen" layouts, make the parent a flex column and give the child flex: 1 (§04, and pitfall #7 in §11).
When content doesn't fit: overflow
If a box has a fixed height and its content is taller, the content spills out. overflow decides what happens to the extra:
An element with overflow: hidden, auto or scroll becomes a Scroll container. You'll need that idea for sticky positioning in §07, where it's the cause of a very common bug.
Flexbox: one direction at a time
display: flex lines up the children along one axis and shares out the space. Everything else is about which axis, and how to share.Two axes
Flexbox thinks in two directions:
- The Main axis is the direction items are lined up in. With
flex-direction: row(the default) it runs left → right. Withcolumnit runs top → bottom. - The Cross axis is at right angles to it: vertical in a row, horizontal in a column.
Every flex property works along one of them. That's why "centring vertically" is align-items in a row but justify-content in a column: the axes swapped.
| Property (on the container) | Axis | What it does |
|---|---|---|
flex-direction | — | row, row-reverse, column, column-reverse: which way the main axis runs |
justify-content | main | where leftover space goes: flex-start, center, flex-end; space-between (all of it between items, none at the edges); space-around (equal space around each item, so the edges get half a gap); space-evenly (every gap, edges included, the same) |
align-items | cross | how items sit across the line: stretch (the default), center, flex-start, baseline, … |
flex-wrap | — | nowrap (the default: one line, items squeeze) or wrap (start a new line when full) |
gap | both | space between items (never at the edges); no collapsing, no last-item hacks |
| Property (on an item) | What it does |
|---|---|
flex-grow | how much of the extra space this item takes (0 = none) |
flex-shrink | how much this item gives up when there's not enough space (0 = never shrink) |
flex-basis | the item's starting size before growing or shrinking |
order | moves the item visually without changing the HTML (careful, §10) |
align-self | overrides align-items for this one item |
Now play. Things to try: set justify-content: space-between; switch to column; give Bee flex-grow: 1; shrink the container to 40% with nowrap and then with wrap; set align-items: baseline and notice the text lines up along its Baseline (the line letters sit on), even though the font sizes differ.
.container {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start; /* main axis */
align-items: stretch; /* cross axis */
gap: 8px;
}The flex shorthand
You'll rarely write flex-grow, flex-shrink and flex-basis separately. flex is a Shorthand property for all three, and its one-word forms are worth memorising:
| You write | Means (grow shrink basis) | Behaviour |
|---|---|---|
| (nothing) | 0 1 auto | Start at my natural size, never grow, shrink if I must. (Measured: Chromium's computed flex for an unstyled item is 0 1 auto.) |
flex: 1 | 1 1 0% | Ignore my content size, split the space equally with other flex: 1 items |
flex: auto | 1 1 auto | Start at my natural size, then share the extra |
flex: none | 0 0 auto | Exactly my natural size, never grow or shrink (icons, buttons) |
flex: 2 | 2 1 0% | Like flex: 1, but take two shares |
The three snippets you'll write most
/* 1. Centre anything, both ways */
.center { display: flex; justify-content: center; align-items: center; }
/* 2. Toolbar: logo left, actions right */
.toolbar { display: flex; align-items: center; gap: 12px; }
.toolbar .spacer { margin-left: auto; } /* ← an auto margin eats all free space */
/* 3. Sidebar + main content */
.layout { display: flex; gap: 24px; }
.sidebar { flex: none; width: 240px; }
.main { flex: 1; min-width: 0; } /* ← min-width: 0 explained in §05 */How flex decides sizes
flex-grow — or takes it back by flex-shrink × basis."Why is this column 150 and not 200?" has a precise answer. The spec calls the procedure resolving flexible lengths. Here it is with numbers first, then as a rule.
Growing: a worked example
A display: flex row 600px wide, with three items:
.a { flex: 1 1 100px; } /* grow 1, basis 100px */
.b { flex: 2 1 200px; } /* grow 2, basis 200px */
.c { flex: 1 1 100px; } /* grow 1, basis 100px */- Lay the items out at their flex-basis: 100 + 200 + 100 = 400px.
- Free space = 600 − 400 = +200px. Positive, so items grow.
- Total grow = 1 + 2 + 1 = 4. Each item gets its share of the 200: A gets 200 × ¼ = 50, B gets 200 × 2/4 = 100, C gets 50.
- Final: A = 150, B = 300, C = 150. (Measured in Chromium: exactly 150 / 300 / 150.)
Notice what flex-grow: 2 did not do: B is not twice as wide as A. It got twice as much of the extra. Only when every basis is 0 (as with flex: 1) do the grow numbers become the final ratio.
Shrinking is weighted by size
Now the row is 300px and the items don't fit:
.a { flex: 0 1 200px; }
.b { flex: 0 1 200px; }
.c { flex: 0 1 100px; }Bases sum to 500, so free space is −200. You might expect each item to give up a third. Instead, shrinking is weighted by flex-shrink × basis, so big items give up more: weights 200, 200, 100 (total 500). A loses 200 × 200/500 = 80, B loses 80, C loses 40. Final 120 / 120 / 60 — every item kept the same 60% of its basis. (Measured in Chromium: 120 / 120 / 60.)
The reason is fairness: shrinking a 100px icon by 67px would wreck it, while a 200px column barely notices. Try both presets, then change numbers:
display: flex; width: 600px- 1Start from each item's flex-basis: 100 + 200 + 100 = 400px.
- 2Free space = container − bases = 600 − 400 = +200px. Positive, so items grow.
- 3Split the free space by flex-grow (total 4): A gets 200 × 1/4 = 50, B gets 200 × 2/4 = 100, C gets 200 × 1/4 = 50.
- 4Final sizes: A = 150px, B = 300px, C = 150px. Browser measured: …
The real algorithm has one more loop: if an item would shrink below its minimum size, it's pinned there ("frozen") and the rest is shared again among the others. In the demo every item has min-width: 0, so the only minimum is zero. In real pages the minimum is usually not zero — and that's the next bug.
The overflow bug: min-width: auto
The default minimum width of a flex item is not 0. It's min-width: auto, which for a flex item means "never smaller than my content's min-content size" — the narrowest it can be without anything overflowing. For normal text that's the longest word. For a file name with white-space: nowrap ("never wrap this text"), it's the whole name. The widget below also uses text-overflow: ellipsis, which draws "…" where clipped text is cut off — but only once the box is actually allowed to be narrower than the text.
So flex refuses to shrink the item, and the row overflows instead:
.row { display: flex; gap: 10px; }
.name { flex: 1; /* nothing: min-width is auto */ }
.name .file { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }This is behind most "my flex item is overflowing" questions: long URLs, code blocks, tables, or a nested flex row inside a flex: 1 column. The fix is one line on the item that should be allowed to shrink:
.name { flex: 1; }
/* min-width: auto → can't shrink
below the full file name */.name {
flex: 1;
min-width: 0; /* ← may shrink below its content */
}overflow: hidden (or any overflow other than visible) on the item also works, because the automatic minimum only applies to items whose overflow is visible. In a column flex container the same bug appears vertically, and the fix is min-height: 0.
Grid: rows and columns together
Tracks, fr, minmax and repeat
You define CSS Grid columns with grid-template-columns, as a list of tracks:
.grid {
display: grid;
grid-template-columns: 200px 1fr 2fr; /* three columns */
gap: 16px; /* between rows and columns */
}200pxis a fixed track.- The fr unit is a share of what's left after fixed tracks and gaps. In a 1000px container: 1000 − 200 − 2×16 = 768px left, split 1 : 2 → 256px and 512px.
minmax(150px, 1fr)means "at least 150px, at most one share".repeat(3, 1fr)is short for1fr 1fr 1fr.
Items you don't place yourself go into the next free cell, left to right, then a new row: Auto-placement. Rows are created as needed (grid-template-rows lets you size them).
auto-fill vs auto-fit
The most useful line in grid is a responsive card grid with no media queries:
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));"Make as many columns as fit, each at least 150px, then stretch them to fill the row." It has a twin, auto-fit, and the difference only shows when there are fewer items than columns. Measured in Chromium, three cards in an 800px grid:
| Tracks created | Each card | |
|---|---|---|
auto-fill | 5 × 160px (two stay empty) | 160px |
auto-fit | 5, but the 2 empty ones collapse to 0px | ≈ 266.7px |
Pick a preset, set items to 2 or 3, and drag the width. The dashed columns are the real tracks, read back from the browser with getComputedStyle:
grid-template-columns: … (0 tracks in a …px box). Three equal columns; extra items flow into new rows automatically (auto-placement)..grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}Named areas: draw the layout as text
For page-level layouts, give areas names and draw them:
.page {
display: grid;
grid-template-columns: 160px 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
}
header { grid-area: header; }
aside { grid-area: sidebar; }
main { grid-area: main; }
footer { grid-area: footer; }You can also place items by line numbers. A grid's lines are the edges between tracks: 3 columns have 4 column lines. They're numbered from 1 at the start edge; negative numbers count from the end, so -1 is always the last line:
.grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: 56px 56px; }
.hero { grid-column: 1 / -1; }The same rules as code:
.hero { grid-column: 1 / -1; } /* from the first line to the last: full width */
.wide { grid-column: span 2; } /* take two columns, wherever auto-placement puts me */
.pinned { grid-column: 2; grid-row: 1 / 3; } /* column 2, rows 1–2 */Grid blowout: the grid version of §05's bug
1fr has the same hidden minimum as a flex item: a track 1fr is really minmax(auto, 1fr), and auto as a minimum means "at least the content's min-content width". A long unbreakable line (a <pre> block, a URL) then stretches its column. Measured in Chromium, grid-template-columns: 1fr 1fr in a 400px grid with a long <pre> in column 2: the columns came out 7px and 438px — 445px in total, so the grid now overflows its own 400px box. (7px is column 1's min-content: the single letter in it.) With minmax(0, 1fr) minmax(0, 1fr) they're 200 and 200, and the <pre> scrolls inside its cell (give it overflow: auto).
Grid or flex?
| Use flex when… | Use grid when… |
|---|---|
| it's one row or one column | you need rows and columns to line up |
| the items' sizes should decide the layout (tags, toolbars, buttons) | the layout should decide the items' sizes (page shells, card galleries, forms) |
| you want wrapping lines that don't align with each other | items in different rows must share column edges |
They nest happily: a grid page shell, whose header is a flex toolbar, whose cards are flex columns. Neither is "more modern"; they solve different problems.
Positioning and containing blocks
position decides whether a box stays in the flow, and what its top/left are measured from. That "from what" is the containing block.position: | In the flow? | top/left measured from | Typical use |
|---|---|---|---|
static (default) | yes | ignored | everything |
relative | yes, original spot kept | its own normal position | small nudges; making a parent the anchor for absolute children |
absolute | no | the Containing block: nearest ancestor with position ≠ static | badges, dropdowns, overlays |
fixed | no | the Viewport | toasts, chat buttons, cookie banners |
sticky | yes | its nearest Scroll container, but it can't leave its parent | sticky table headers, section titles |
"Out of flow" means the element no longer takes up space: the browser lays everything else out as if it weren't there, then draws it on top. Try each value, scroll inside the frame, and toggle the parent:
top/left. Its original spot (dashed) stays reserved, so the text around it doesn't move.The "relative parent, absolute child" pattern
The most common positioning code in the world:
.avatar { position: relative; } /* ← makes .avatar the containing block */
.avatar .badge {
position: absolute;
top: -4px;
right: -4px; /* pinned to the avatar's top-right corner */
}position: relative with no offsets doesn't move the avatar at all. It's there only so the badge measures from the avatar instead of from some ancestor further up. Forget it, and the badge flies to the corner of the page (or of whichever ancestor happens to be positioned).
Sticky: why doesn't it stick?
position: sticky fails silently. Each of these was checked in headless Chromium by scrolling the page 500px and reading where the element ended up:
- No threshold. Sticky needs
top(orbottom,left,right). Without one it never sticks. Measured: scrolled off-screen with the page. - An ancestor is a scroll container. Sticky sticks inside its nearest scroll container. If some wrapper has
overflow: hidden,autoorscroll— even justoverflow-x: hidden, often added to stop sideways scrolling — then that wrapper is the scroll container. The wrapper itself doesn't scroll; the page does; so the element scrolls away. Measured:overflow: hiddenandoverflow-x: hiddenboth broke it;overflow: clipdid not, becauseclipdoesn't create a scroll container. - The parent is too short. A sticky element can never leave its parent box. If the parent is barely taller than the sticky element, there's no room to stick. Measured: in a 60px parent, a 20px sticky header moved only 40px before scrolling away.
Fixed isn't always fixed
position: fixed measures from the viewport — unless an ancestor has a transform, filter, perspective, contain: paint or a similar property. Then that ancestor becomes the containing block, and the "fixed" element scrolls with it. Measured in Chromium: a fixed; top: 0; left: 0 element inside a transformed box at (100, 300) landed at (100, 300), not (0, 0).
The demo above uses this on purpose: its frame has transform: translateZ(0) — a transform that moves nothing ("shift by 0 towards the viewer"), a common trick to turn on the side effects of having a transform — so fixed stays inside it. On a real page it's a bug, typically a modal or toast rendered inside an animated container. The usual fix is to render the modal directly under body (with a Portal in React, §08).
Stacking contexts: why z-index: 9999 loses
z-index only ranks elements inside the same stacking context. A context is sealed: everything in it is drawn together, at the context's own level.First, the property itself: z-index is a number on an element that says how close to you it's drawn when boxes overlap (higher = closer). It works on positioned elements (position other than static) and on flex and grid items.
The bug, live
The setup most people hit: a modal with z-index: 9999 inside some page wrapper, and a header with z-index: 2 next to that wrapper. The widget renders it for real and asks your browser who's on top with document.elementFromPoint (which returns the topmost element at a point on screen):
z-index: 9999
z-index: 2
elementFromPoint at the white ring: … Things to try:
- Start as it is: Parent A has
z-index: 1. The header wins, despite 9999. - Set Parent A's
z-indexto auto: A no longer forms a context, the modal competes at the page level, and wins. - Keep
autobut setopacity: 0.99. The modal loses again: opacity below 1 also creates a context. - Set Parent A's
z-indexto 3. The modal wins even withz-index: 1, because the whole box A now sits above the header.
What creates a stacking context
The page's root (html) is one. After that, any element with any of these makes a new one. Every line was checked in Chromium with the experiment above (modal at z-index: 9999 inside the element, header at 2 outside it → the header won every time):
position: relativeorabsolutewith az-indexother thanautoposition: fixedorsticky(always, even withoutz-index)- a flex or grid item with a
z-indexother thanauto opacityless than 1transform,filter,backdrop-filter,perspective,clip-path,maskother thannone- will-change naming one of the properties above
isolation: isolatemix-blend-modeother thannormal, andcontain: layoutorpaint
In practice you'll mostly meet the first four bullets plus opacity, transform, filter and will-change. The rest (backdrop-filter blurs what's behind a box, clip-path and mask cut a box into a shape, perspective sets up 3D, mix-blend-mode blends colours like a photo editor, contain limits how far layout changes spread) are rarer visual effects; just remember that they count too.
The sneaky ones are opacity, transform and will-change: you add them for a fade-in animation or a performance tweak, and a dropdown somewhere inside quietly stops overlapping things.
How z-index is compared
Two more facts complete the picture:
z-indexdoes nothing on a plainstaticelement (except flex and grid items, §04). Measured: a static block withz-index: 5was still covered by an absolutely positionedz-index: 1box.- Inside one context, the browser paints in a fixed Paint order. Positioned elements always paint after (on top of) normal-flow blocks, even with no
z-indexat all. That's why addingposition: relativeto something can suddenly put it on top.
A detail from layer 2: a child with z-index: -1 is painted behind its parent's background only if the parent is not a stacking context (the child then drops to the layer of some ancestor that is). Measured: under a plain parent it disappeared behind the parent's yellow background; once the parent got position: relative; z-index: 0 (or isolation: isolate) it was visible again, between that background and the parent's text.
Fixes that scale
/* The z-index arms race */
.modal { z-index: 9999; }
.toast { z-index: 99999; }
.dropdown { z-index: 999999; } /* … *//* Seal components so their z-index stays inside */
.card { isolation: isolate; }
/* A small, named scale for the top level */
:root {
--z-dropdown: 10;
--z-header: 20;
--z-modal: 30;
--z-toast: 40;
}isolation: isolatecreates a stacking context and does nothing else: no visual change, no new containing block. Put it on components so their internalz-indexvalues can't leak out and fight the rest of the page.- Keep the page-level layers in a short named scale, so a new layer has an obvious place.
- Move overlays out of the trap. Modals, toasts and tooltips should live at the top of the document, not deep inside whatever component opened them. In React that's a Portal:
import { createPortal } from "react-dom";
export function Modal({ children }) {
// Rendered as a direct child of <body>: no ancestor's transform,
// opacity or z-index can trap it.
return createPortal(<div className="modal">{children}</div>, document.body);
}The native <dialog> element opened with showModal() goes one step further: the browser puts it in the top layer, a layer above every stacking context on the page, so no z-index can cover it. The same goes for elements using the popover attribute (an HTML attribute for menus and tooltips that the browser shows and hides for you).
Responsive basics
Units
| Unit | Relative to | Use it for |
|---|---|---|
px | nothing (a CSS pixel) | borders, shadows, small fixed details |
% | the parent's size (for width: its width) | fluid widths |
rem | the root font size (16px unless the user changed it) | font sizes, spacing |
em | the current font size | things that should scale with their own text, like a button's padding |
vw / vh | 1% of the viewport's width / height | full-screen sections |
dvh / svh / lvh | the viewport height on phones, as it changes / when smallest / when largest | full-height mobile layouts |
ch | the width of the "0" character in the current font | readable line lengths: max-width: 65ch |
em compounds; rem doesn't. With a 20px parent, a child at font-size: 1.5em, and a grandchild at 1.5em again, the grandchild is 20 × 1.5 × 1.5 = 45px. The same grandchild at 1.5rem is 16 × 1.5 = 24px, wherever it sits. (Both measured in Chromium.) That's why most codebases size text in rem.
Why not 100vh on phones? On mobile, the address bar slides in and out as you scroll, so the visible height changes. 100vh is the height with the bar hidden, so a 100vh hero is taller than the screen when the bar is showing, and the bottom is cut off. The newer Viewport units fix this: 100dvh follows the bar as it moves, 100svh always assumes the bar is visible. (Supported in all current major browsers since 2022.)
Media queries vs container queries
A Media query asks about the window:
@media (min-width: 768px) { /* only when the window is ≥ 768px wide */
.layout { grid-template-columns: 240px 1fr; }
}That's right for page-level decisions, like "show the sidebar". It's wrong for components. A card component doesn't know whether it'll be placed in a wide main column or a narrow sidebar; the window is 1440px wide either way. A Container query asks about the size of an ancestor instead:
Weekend in the mountains
@container (min-width: 240px) is false, columns = …, heading font-size = …. A @media (min-width: 240px) rule only looks at the window (…px wide), so it would stay false however you resize this box..rlab-wrap { container-type: inline-size; }
.rlab-card { display: grid; grid-template-columns: 1fr; gap: 12px; }
@container (min-width: 240px) {
.rlab-card { grid-template-columns: 90px 1fr; }
}
.rlab-card h4 { font-size: clamp(16px, 10px + 3cqi, 28px); margin: 0; }Sizes that come from the content
So far, sizes came from the parent (%, fr) or were fixed (px). Three keywords ask the content instead:
You've met min-content already: it's the hidden minimum behind the flex and grid bugs in §05 and §06. max-content is its opposite. fit-content is the useful one day to day: "shrink-wrap the content, but never wider than the space I have". Measured in Chromium with the widget's own sentence ("Layout is just arranging furniture in rooms", 14px text, 10px side padding, 2px border): min-content = 87.6px (the longest word, "furniture", plus 20px of padding and 4px of border), and max-content = fit-content = 299.9px when the container has room. Shrink the container below 300px and fit-content follows it, while max-content overflows.
clamp(): fluid sizes with limits
clamp takes a minimum, a preferred value and a maximum:
h1 {
/* at least 1rem, at most 2rem, otherwise half a rem plus 2% of the window width */
font-size: clamp(1rem, 0.5rem + 2vw, 2rem);
}Worked through for three window widths (and measured in Chromium):
| Window | Preferred: 8px + 2% of width | Result |
|---|---|---|
| 375px (phone) | 8 + 7.5 = 15.5px | 16px (the minimum wins) |
| 1000px | 8 + 20 = 28px | 28px |
| 1440px | 8 + 28.8 = 36.8px | 32px (the maximum wins) |
Text that grows smoothly with the screen, with no breakpoints, and never too small or too big. One caution: if the preferred value is mostly vw, browser zoom barely changes it, so always mix in a rem part as above.
Layout at scale
1. Layout costs time, and you can measure it
Every time a size or position might have changed, the browser must re-run layout on the Main thread, in the same thread that runs your JavaScript. On a big page that adds up. Browser Rendering covers the pipeline, the Frame budget and layout thrashing (reading sizes and writing styles in a loop); here are two layout-specific levers.
Skip what's off-screen. content-visibility lets the browser skip style and layout for elements that are far from the viewport. A feed of 3,000 cards (each a flex row with an image and two lines of text), measured on this machine in headless Chromium, median of 7 runs:
| First style + layout | Layout after changing the feed's width | |
|---|---|---|
| plain | 57 ms | 40 ms |
content-visibility: auto; contain-intrinsic-size: auto 120px | 7 ms | 2 ms |
.feed > article {
content-visibility: auto; /* skip layout + paint while off-screen */
contain-intrinsic-size: auto 120px; /* ← placeholder height so the scrollbar doesn't jump */
}The catch: off-screen content still exists, but its size is a guess until it's rendered, so the scrollbar can shift slightly, and in-page search (Ctrl+F) and anchor links need the browser to render it first (Chromium does this automatically). Measure on your own page before shipping.
Keep changes local. A change to one element's width can ripple to its parent, siblings and children. Fixed-size containers (an explicit width and height, or contain: size), and animating transform instead of top/left/width, keep the work small. transform moves pixels in the Compositing step without re-running layout at all.
2. Logical properties: write once, work in every direction
Arabic, Hebrew, Persian and Urdu are written right to left. With dir="rtl" on the page, flex rows, grid columns and text all start at the right, automatically. But margin-left still means left:
.msg { margin-left: 12px; border-left: 3px solid; padding-left: 8px; }Logical properties name sides by the flow of text instead:
| Physical | Logical | Meaning |
|---|---|---|
margin-left | margin-inline-start | before the text, along the line |
padding-right | padding-inline-end | after the text, along the line |
margin-top / margin-bottom | margin-block-start / margin-block-end | before / after, between lines |
width / height | inline-size / block-size | size along / across the line (the same as width/height in any horizontal language, LTR or RTL; they swap only in vertical writing) |
left / right | inset-inline-start / inset-inline-end | positioning offsets |
text-align: left | text-align: start |
Flex and grid were designed this way from the start: flex-start and justify-content: start already flip. The habit costs nothing in a left-to-right-only product and saves a rewrite the day you ship to a right-to-left market.
3. Subgrid: aligning things across cards
Three product cards in a grid, each with a title, a description and a price. Titles are different lengths, so the prices don't line up across cards: each card is its own little layout. Subgrid fixes this by letting each card reuse the parent's row tracks:
.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.card {
display: grid;
grid-row: span 3; /* each card covers three of the parent's rows */
grid-template-rows: subgrid; /* ← and uses those rows instead of its own */
}
/* now every card's title row, text row and price row share the same heights */Subgrid is supported in all current major browsers (since Chrome 117 in 2023). Before it, teams used fixed heights or JavaScript measuring, both of which break with translated text.
4. Spacing as a system
On a large codebase the layout bugs that hurt most are inconsistencies: 13px here, 15px there, a margin on one card but not its neighbour. The fixes are organisational:
- A spacing scale as design tokens:
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; …. Code reviews then ask "why not a token?" instead of arguing about pixels. - The parent owns spacing. A reusable component has no outer margin; the layout that places it sets
gap. Then the component works anywhere, and §02's collapsing can't happen. - Fewer layout primitives, used everywhere: a
Stack(vertical flex with gap), aCluster(wrapping row with gap), aGrid(auto-fill cards), aSidebarlayout. Most screens are combinations of these four.
5. Visual order is not reading order
order, flex-direction: row-reverse, grid-template-areas and explicit grid placement all move boxes on screen without changing the HTML. But keyboard focus (Tab), screen readers and copy-paste all follow the HTML order:
Measured in headless Chromium with row-reverse and order: -1 on the third button: on screen the buttons sat left to right as Two, One, Three, but Tab still visited them in HTML order — One, Two, Three — so focus went middle, then left, then right. For a sighted keyboard user that's disorienting, and the WCAG guidelines call it out as a failure of "meaningful sequence" and "focus order" (Accessibility covers them).
Pitfalls gallery
Eight bugs that show up in real code reviews, each with the one-line reason and the fix.
width: 100% plus padding overflows
With the default content-box, padding is added on top of the 100%. The input ends up wider than its form.
input {
width: 100%;
padding: 0 12px; /* 100% + 24px */
}*, *::before, *::after {
box-sizing: border-box;
}
input { width: 100%; padding: 0 12px; }A flex item won't shrink, the row overflows
The default min-width: auto keeps a flex item at least as wide as its longest unbreakable content: a URL, a nowrap name, a code block, a nested flex row. (§05)
.main { flex: 1; }.main { flex: 1; min-width: 0; }
/* column direction? min-height: 0 */A 1fr column stretches to fit a <pre>
1fr is really minmax(auto, 1fr): never smaller than its content's min-content. (§06)
.grid { grid-template-columns: 1fr 1fr; }.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
pre { overflow: auto; }z-index: 9999 and it's still behind
Some ancestor creates a stacking context (z-index with position, opacity < 1, transform, filter, will-change…), and that ancestor sits below the other element. Raising the child's number can never help. (§08)
.modal { z-index: 999999; }/* 1. In DevTools, walk up from .modal to the first
ancestor that creates a stacking context, and
fix the layering there — or
2. render the modal in a portal under <body>, or
use <dialog> with showModal() (top layer). */The absolute badge flies to the page corner
position: absolute measures from the nearest positioned ancestor. If none is, it climbs all the way up to the page. (§07)
.avatar .badge {
position: absolute; top: 0; right: 0;
}.avatar { position: relative; } /* the anchor */
.avatar .badge {
position: absolute; top: 0; right: 0;
}Sticky header doesn't stick
An ancestor has overflow: hidden / auto / scroll (often an innocent overflow-x: hidden), there's no top, or the parent is too short. (§07)
.page { overflow-x: hidden; }
.page header { position: sticky; top: 0; }.page { overflow-x: clip; } /* clips, but isn't a scroll container */
.page header { position: sticky; top: 0; }height: 100% does nothing
A percentage height needs a parent with a known height. If the parent's height is auto (it depends on the child), the percentage is ignored. (§03)
.wrapper { }
.panel { height: 100%; }.wrapper { display: flex; flex-direction: column; min-height: 100dvh; }
.panel { flex: 1; } /* fills the remaining height */A child's margin pushes the whole parent down
A first child's margin-top collapses through a parent that has no border, padding or layout of its own. The gap appears above the parent, not inside it. (§02)
.card h2 { margin-top: 24px; }
/* the gap shows up above .card */.card { display: flow-root; } /* or padding, or flex/grid + gap */
.card h2 { margin-top: 24px; }Check yourself: predict the layout
Work each one out on paper before you click. Every answer was checked in headless Chromium, and each explanation points back to the section with the rule.
.card {
width: 200px;
padding: 20px;
border: 2px solid;
/* box-sizing not set */
}.a { margin-bottom: 30px; }
.b { margin-top: 20px; }
/* <p class="a"> directly followed by <p class="b"> */<div style="position: relative; z-index: 1">
<div class="modal" style="position: absolute; z-index: 9999"></div>
</div>
<header style="position: relative; z-index: 2"></header>
<!-- the modal and the header overlap -->The container is display: flex; width: 500px with two children:
.x { flex: 1 1 100px; }
.y { flex: 3 1 100px; }Now the container is width: 300px:
.x { flex: 0 1 300px; }
.y { flex: 0 1 100px; }.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}.layout { overflow-x: hidden; } /* added to stop sideways scrolling */
.layout header { position: sticky; top: 0; }Cheat sheet
box-sizing: border-box everywhere so width means the visible box.gap on the parent.flex-grow; shrink takes by flex-shrink × basis.minmax(0, 1fr).repeat(auto-fill, minmax(150px, 1fr)) for card grids; named areas for page shells. Grid for 2D, flex for 1D.position: relative on the anchor. Sticky needs top and no scroll-container ancestor in between.isolation: isolate, a z-scale, and portals or <dialog>.rem text, ch line length, dvh on phones, clamp() for fluid sizes, container queries for components.| If you want… | Write |
|---|---|
| Centre anything | display: flex; justify-content: center; align-items: center; (or display: grid; place-items: center; — place-items is shorthand for align-items + justify-items) |
| Push the last item to the right | margin-left: auto on it, inside a flex row |
| Equal columns | display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); |
| Responsive cards, no media queries | grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
| Sidebar + content | display: flex with .side { flex: none; width: 240px } and .main { flex: 1; min-width: 0 } |
| Badge on a corner | parent position: relative, badge position: absolute; top: 0; right: 0 |
| Overlay over a box | parent position: relative, overlay position: absolute; inset: 0 |
| Stop margin leaking out of a parent | display: flow-root on the parent |
| Keep a component's z-index inside it | isolation: isolate |
| One-line text with … | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; (+ min-width: 0 on the flex item) |
| Full-height mobile section | min-height: 100dvh |
| Readable paragraph width | max-width: 65ch |
| Fluid heading | font-size: clamp(1.5rem, 1rem + 2vw, 2.5rem) |
| Works in right-to-left too | margin-inline-start, padding-inline, inset-inline-end, text-align: start |