Skip to content
Software Atlas

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.

⏱ ~50 min read13 sections17 interactive widgets + quizCSS · HTML · JS
FoundationsPractitionerStaff-level
Foundations§01

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.

In one sentenceEvery element is a rectangle. CSS layout is a handful of rule sets for sizing those rectangles and arranging them inside their parent.

The same four boxes, arranged by five different rules. Nothing about the boxes changes — only the CSS on the room:

Same furniture, five arranging rules
The four boxes never change. Only the room's layout mode does.
.room
sofa
table
lamp
chair
.room { }                /* nothing set: "normal flow" */
.item { display: block; } /* each block starts on a new line */
Normal flow: block boxes stack top to bottom, like boxes piled against the left wall. Nobody asked for this — it's what happens when you set nothing.

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.

1

Sizes come from rules

A box's width depends on its box-sizing, its parent's layout mode and its content. Learn the rules and "why is this 244px?" becomes arithmetic.
2

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.
3

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.
Foundations§02

The box model

In one sentenceEvery box has four layers: content, padding, border, margin. box-sizing decides which of them width measures.

From the inside out, using our furniture box:

LayerFurnitureWhat it isPainted?
contentthe sofa itselftext, images, child elementsyes
paddingbubble wrapspace between content and border; shows the element's backgroundyes (background)
borderthe cardboarda line around the paddingyes
marginelbow roomempty space outside the border that pushes neighbours awayno, always transparent

Sizes in this topic are in CSS pixels (px). Try the inspector: change the sliders, then flip box-sizing.

Box model inspector
Drag the sliders. The orange box is a real element; its size is read back from the browser.
box-sizing
margin16
border6
padding20
200 × 44
the same picture DevTools draws (F12 → Elements → Computed)
content
measured live
With 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:

reset.css
*, *::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:

Margin collapsing, measured
Vertical margins between blocks don't add up — they overlap.
1 · Two siblings
margin-bottom: 30px
margin-top: 20px
measured live Gap between them: …px — not 30 + 20 = 50. Adjacent vertical margins collapse into the larger one: max(30, 20) = 30.
2 · A child's margin leaks out
parent gets
element above
child · margin-top: 30px
parent (pink)
measured live Space above the parent: …px. Space inside, above the child: …px. The child's 30px margin escaped through the parent's top edge, pushing the whole parent down. Nothing (no border, padding or new layout) separates the two margins, so they collapse together.

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".

Foundations§03

Normal flow: block and inline

In one sentenceWith no layout CSS at all, the browser lays boxes out like a book: blocks stack down the page, inline content flows along lines.

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:

display decides how a box joins the flow
The cyan element has width, height, padding and margin set. Which of them actually apply?
display
width
Some words before the element and some words after it, flowing on like an ordinary sentence until the line runs out of room.
.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;
}
measured live Rendered size: …. inline: flows inside the line like a word. 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…
blocka block (new line, full width)normal flow
inlinea word in a line(inline content only)
inline-blocka word in a line, but sized like a blocknormal flow
flex / inline-flexa block / an inline boxflexbox (§04)
grid / inline-grida block / an inline boxgrid (§06)
flow-roota blocknormal flow, as a fresh layout region (§02)
none— not rendered at all, takes no space—
contentsthe 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:

When content doesn't fit
A box with height: 90px and six lines of content.
overflow
line 1 of the content
line 2 of the content
line 3 of the content
line 4 of the content
line 5 of the content
line 6 of the content
the next element
visible (default): content spills out of the box and paints over whatever comes next. The box itself stays 90px tall.

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.

Foundations§04

Flexbox: one direction at a time

In one sentencedisplay: 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. With column it 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)AxisWhat it does
flex-direction—row, row-reverse, column, column-reverse: which way the main axis runs
justify-contentmainwhere 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-itemscrosshow 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)
gapbothspace between items (never at the edges); no collapsing, no last-item hacks
Property (on an item)What it does
flex-growhow much of the extra space this item takes (0 = none)
flex-shrinkhow much this item gives up when there's not enough space (0 = never shrink)
flex-basisthe item's starting size before growing or shrinking
ordermoves the item visually without changing the HTML (careful, §10)
align-selfoverrides 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.

Flexbox playground
Real CSS, rendered live. Click an item to edit it. Sizes under the box are read back from the browser.
Container
flex-direction
flex-wrap
justify-content
align-items
Item .item-b (Bee)
flex-grow
flex-shrink
flex-basis
order
align-self
━━▶ main axis: horizontal, left → right┃ cross axis: vertical
measured live
.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 writeMeans (grow shrink basis)Behaviour
(nothing)0 1 autoStart at my natural size, never grow, shrink if I must. (Measured: Chromium's computed flex for an unstyled item is 0 1 auto.)
flex: 11 1 0%Ignore my content size, split the space equally with other flex: 1 items
flex: auto1 1 autoStart at my natural size, then share the extra
flex: none0 0 autoExactly my natural size, never grow or shrink (icons, buttons)
flex: 22 1 0%Like flex: 1, but take two shares

The three snippets you'll write most

flex-recipes.css
/* 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 */
Practitioner§05

How flex decides sizes

In one sentenceFlex starts every item at its basis, measures the leftover (or missing) space, and hands it out by 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 */
  1. Lay the items out at their flex-basis: 100 + 200 + 100 = 400px.
  2. Free space = 600 − 400 = +200px. Positive, so items grow.
  3. 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.
  4. 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:

Do the flex math, then check it against the browser
Three items, gap 0, no padding or border, min-width: 0 (so content can't interfere).
item Aflex-basis 100flex-grow 1flex-shrink 1
item Bflex-basis 200flex-grow 2flex-shrink 1
item Cflex-basis 100flex-grow 1flex-shrink 1
1 · the bases, laid end to end, vs the container (dashed)
A 100
B 200
C 100
free +200
2 · what your browser rendered with display: flex; width: 600px
A
B
C
Drawn at real size (1 CSS px = 1 px); scroll sideways on a narrow screen.
  1. 1Start from each item's flex-basis: 100 + 200 + 100 = 400px.
  2. 2Free space = container − bases = 600 − 400 = +200px. Positive, so items grow.
  3. 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.
  4. 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:

Why is my flex item overflowing?
A file row: avatar, file name (one line, … when too long), button. The middle column has flex: 1.
middle column
quarterly-report-final-FINAL-v2-really-final.pdf
2.4 MB
Download
.row  { display: flex; gap: 10px; }
.name { flex: 1; /* nothing: min-width is auto */ }
.name .file { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
measured live Middle column: …px wide in a …px card. Nothing overflows: the name is cut with “…”, the button stays inside. A flex item's default minimum width is its content's minimum width — here, the whole unbreakable file name. So flex refuses to shrink it below that, and text-overflow never gets a chance.

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:

✕ Don't
.name { flex: 1; }
/* min-width: auto → can't shrink
   below the full file name */
✓ Do
.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.

Practitioner§06

Grid: rows and columns together

In one sentenceFlexbox arranges items and lets the lines form. Grid draws the floor plan first — rows and columns — then drops items into the cells.

Tracks, fr, minmax and repeat

You define CSS Grid columns with grid-template-columns, as a list of tracks:

grid-basics.css
.grid {
  display: grid;
  grid-template-columns: 200px 1fr 2fr;  /* three columns */
  gap: 16px;                             /* between rows and columns */
}
  • 200px is 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 for 1fr 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 createdEach card
auto-fill5 × 160px (two stay empty)160px
auto-fit5, 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 playground
Pick a column template, then resize the container. Dashed columns are the tracks the browser created.
grid-template-columns
item 1
1
2
3
4
5
measured live Computed 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:

Draw the layout as text
Same four elements, same HTML order (header, sidebar, main, footer). Only the template changes.
<header> grid-area: header
<aside> grid-area: sidebar
<main> grid-area: main
<footer> grid-area: footer
.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 lines are numbered, not cells
A 3-column, 2-row grid. Numbers along the top count lines from the start; along the bottom, negative numbers count from the end.
1-4
2-3
3-2
4-1
1
2
3
.hero
.grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: 56px 56px; }
.hero { grid-column: 1 / -1; }
From line 1 to line −1 (the last line, also called 4): the full width, whatever the number of columns.

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 columnyou 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 otheritems 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.

Practitioner§07

Positioning and containing blocks

In one sentenceposition 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 fromTypical use
static (default)yesignoredeverything
relativeyes, original spot keptits own normal positionsmall nudges; making a parent the anchor for absolute children
absolutenothe Containing block: nearest ancestor with position ≠ staticbadges, dropdowns, overlays
fixednothe Viewporttoasts, chat buttons, cookie banners
stickyyesits nearest Scroll container, but it can't leave its parentsticky 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:

Positioning lab
The pink box has top and left set. Change its position, then scroll inside the frame.
position
parent box
↓ scroll inside this frame
Some text above the parent box.
parent box · position: static
A line of text before the pink box.
position: relative
A line of text after the pink box.
More of the parent box's content…
filler line 1 — keep scrolling
filler line 2 — keep scrolling
filler line 3 — keep scrolling
filler line 4 — keep scrolling
filler line 5 — keep scrolling
filler line 6 — keep scrolling
filler line 7 — keep scrolling
filler line 8 — keep scrolling
filler line 9 — keep scrolling
filler line 10 — keep scrolling
filler line 11 — keep scrolling
filler line 12 — keep scrolling
filler line 13 — keep scrolling
filler line 14 — keep scrolling
relative: laid out in flow first, then nudged by 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:

badge.css
.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:

  1. No threshold. Sticky needs top (or bottom, left, right). Without one it never sticks. Measured: scrolled off-screen with the page.
  2. An ancestor is a scroll container. Sticky sticks inside its nearest scroll container. If some wrapper has overflow: hidden, auto or scroll — even just overflow-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: hidden and overflow-x: hidden both broke it; overflow: clip did not, because clip doesn't create a scroll container.
  3. 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).

Practitioner§08

Stacking contexts: why z-index: 9999 loses

In one sentencez-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):

Why z-index: 9999 loses
A modal (pink) lives inside Parent A. The header (cyan) is a sibling of A with z-index: 2. Who is on top where they overlap?
Parent A
Modal
z-index: 9999
Header
z-index: 2
Prediction: header on top · measured live elementFromPoint at the white ring: …
Parent A
z-index
opacity
transform
isolation
Modal (inside A)
z-index
Stacking-context tree
▣ root stacking context (the page)
├─ ▣ Parent A · level 1 ← position: relative + z-index: 1
│   └─ Modal · z 9999 (competes only inside A)
└─ Header · z 2
Parent A is a stacking context at level 1. At the root, the header's 2 beats 1. The modal's z-index: 9999 only competes inside A, so it can never get above the header.

Things to try:

  1. Start as it is: Parent A has z-index: 1. The header wins, despite 9999.
  2. Set Parent A's z-index to auto: A no longer forms a context, the modal competes at the page level, and wins.
  3. Keep auto but set opacity: 0.99. The modal loses again: opacity below 1 also creates a context.
  4. Set Parent A's z-index to 3. The modal wins even with z-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: relative or absolute with a z-index other than auto
  • position: fixed or sticky (always, even without z-index)
  • a flex or grid item with a z-index other than auto
  • opacity less than 1
  • transform, filter, backdrop-filter, perspective, clip-path, mask other than none
  • will-change naming one of the properties above
  • isolation: isolate
  • mix-blend-mode other than normal, and contain: layout or paint

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-index does nothing on a plain static element (except flex and grid items, §04). Measured: a static block with z-index: 5 was still covered by an absolutely positioned z-index: 1 box.
  • 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-index at all. That's why adding position: relative to something can suddenly put it on top.
Paint order inside one stacking context
Bottom of the list is painted first; each layer covers the ones below it. Ties go to HTML order (later wins).
1Background & border of the element that forms the context
2Positioned children with negative z-index (most negative first)
3In-flow block boxes (normal flow, not positioned)
4Floats
5Inline content: text, images, inline-blocks
6Positioned children with z-index: auto or 0, plus children that form a context at level 0 (opacity, transform…) — in HTML order
7Positioned children with positive z-index (1, 2, … 9999)
↑ painted last = 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

✕ Escalate
/* The z-index arms race */
.modal    { z-index: 9999; }
.toast    { z-index: 99999; }
.dropdown { z-index: 999999; } /* … */
✓ Contain
/* 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: isolate creates a stacking context and does nothing else: no visual change, no new containing block. Put it on components so their internal z-index values 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:
Modal.jsx
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).

Practitioner§09

Responsive basics

In one sentenceResponsive layout is mostly choosing the right unit and letting flex and grid adapt. Breakpoints are for the few changes that can't flow.

Units

UnitRelative toUse it for
pxnothing (a CSS pixel)borders, shadows, small fixed details
%the parent's size (for width: its width)fluid widths
remthe root font size (16px unless the user changed it)font sizes, spacing
emthe current font sizethings that should scale with their own text, like a button's padding
vw / vh1% of the viewport's width / heightfull-screen sections
dvh / svh / lvhthe viewport height on phones, as it changes / when smallest / when largestfull-height mobile layouts
chthe width of the "0" character in the current fontreadable 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:

A card that adapts to its container, not the window
Resize the box. The window stays the same size — only a container query can notice.

Weekend in the mountains

The same component, dropped into a narrow sidebar or a wide main column.
measured live Container: …px → @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:

Sizes that come from the content
One box of text. Only its width keyword changes.
width
Layout is just arranging furniture in rooms
measured live Width: …px. auto on a block: fill the container's width.

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):

WindowPreferred: 8px + 2% of widthResult
375px (phone)8 + 7.5 = 15.5px16px (the minimum wins)
1000px8 + 20 = 28px28px
1440px8 + 28.8 = 36.8px32px (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.

Staff-level§10

Layout at scale

In one sentenceAt scale, layout problems stop being "how do I centre this" and become cost, internationalisation, consistency and accessibility.

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 + layoutLayout after changing the feed's width
plain57 ms40 ms
content-visibility: auto; contain-intrinsic-size: auto 120px7 ms2 ms
feed.css
.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:

margin-left vs margin-inline-start
A notification row: icon, then text with a yellow accent bar on the side next to the icon.
!
Your export is ready
.msg { margin-left: 12px; border-left: 3px solid; padding-left: 8px; }
measured live Space between icon and text: …px. Looks fine in English. Now switch to Arabic.

Logical properties name sides by the flow of text instead:

PhysicalLogicalMeaning
margin-leftmargin-inline-startbefore the text, along the line
padding-rightpadding-inline-endafter the text, along the line
margin-top / margin-bottommargin-block-start / margin-block-endbefore / after, between lines
width / heightinline-size / block-sizesize along / across the line (the same as width/height in any horizontal language, LTR or RTL; they swap only in vertical writing)
left / rightinset-inline-start / inset-inline-endpositioning offsets
text-align: lefttext-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.css
.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), a Cluster (wrapping row with gap), a Grid (auto-fill cards), a Sidebar layout. 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:

What you see vs what Tab follows
Click a button, then press Tab and Shift+Tab. Focus follows the HTML, not the picture.
flex-direction
Delete button
measured live Visual order (left → right): …. Tab order (the HTML): 1 · Save · 2 · Edit · 3 · Delete. They agree.

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).

Practitioner§11

Pitfalls gallery

Eight bugs that show up in real code reviews, each with the one-line reason and the fix.

#1 · box model

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.

✕ Don't
input {
  width: 100%;
  padding: 0 12px;  /* 100% + 24px */
}
✓ Do
*, *::before, *::after {
  box-sizing: border-box;
}
input { width: 100%; padding: 0 12px; }
#2 · flex

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)

✕ Don't
.main { flex: 1; }
✓ Do
.main { flex: 1; min-width: 0; }
/* column direction? min-height: 0 */
#3 · grid

A 1fr column stretches to fit a <pre>

1fr is really minmax(auto, 1fr): never smaller than its content's min-content. (§06)

✕ Don't
.grid { grid-template-columns: 1fr 1fr; }
✓ Do
.grid {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
pre { overflow: auto; }
#4 · stacking

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)

✕ Escalate
.modal { z-index: 999999; }
✓ Find the context, or escape it
/* 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). */
#5 · positioning

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)

✕ Don't
.avatar .badge {
  position: absolute; top: 0; right: 0;
}
✓ Do
.avatar { position: relative; }  /* the anchor */
.avatar .badge {
  position: absolute; top: 0; right: 0;
}
#6 · positioning

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)

✕ Don't
.page { overflow-x: hidden; }
.page header { position: sticky; top: 0; }
✓ Do
.page { overflow-x: clip; }  /* clips, but isn't a scroll container */
.page header { position: sticky; top: 0; }
#7 · normal flow

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)

✕ Don't
.wrapper { }
.panel { height: 100%; }
✓ Do
.wrapper { display: flex; flex-direction: column; min-height: 100dvh; }
.panel { flex: 1; }  /* fills the remaining height */
#8 · margins

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)

✕ Don't
.card h2 { margin-top: 24px; }
/* the gap shows up above .card */
✓ Do
.card { display: flow-root; }  /* or padding, or flex/grid + gap */
.card h2 { margin-top: 24px; }
Practitioner§12

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.

QUESTION 1 / 7
How wide is the visible box?
.card {
  width: 200px;
  padding: 20px;
  border: 2px solid;
  /* box-sizing not set */
}
QUESTION 2 / 7
How big is the gap between the two paragraphs?
.a { margin-bottom: 30px; }
.b { margin-top: 20px; }
/* <p class="a"> directly followed by <p class="b"> */
QUESTION 3 / 7
Who is on top?
<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 -->
QUESTION 4 / 7
Final widths?

The container is display: flex; width: 500px with two children:

.x { flex: 1 1 100px; }
.y { flex: 3 1 100px; }
QUESTION 5 / 7
Final widths when shrinking?

Now the container is width: 300px:

.x { flex: 0 1 300px; }
.y { flex: 0 1 100px; }
QUESTION 6 / 7
Three cards in an 800px grid. How wide is each?
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
QUESTION 7 / 7
Why doesn't this header stick?
.layout { overflow-x: hidden; }   /* added to stop sideways scrolling */
.layout header { position: sticky; top: 0; }
Answered 0 / 7
Foundations§13

Cheat sheet

📦
Every element is a boxContent → padding → border → margin. Use box-sizing: border-box everywhere so width means the visible box.
↕️
Vertical margins collapse in normal flow30 + 20 = 30. Not in flex or grid. Prefer gap on the parent.
➡️
Flex: one axis, shared spaceFree space = container − bases. Grow splits the extra by flex-grow; shrink takes by flex-shrink × basis.
🧱
min-width: 0 fixes most flex overflowFlex items won't shrink below their content by default. In grid, use minmax(0, 1fr).
🗺️
Grid: floor plan firstrepeat(auto-fill, minmax(150px, 1fr)) for card grids; named areas for page shells. Grid for 2D, flex for 1D.
📌
Absolute measures from the nearest positioned ancestorPut position: relative on the anchor. Sticky needs top and no scroll-container ancestor in between.
🪟
z-index is local to its stacking contextopacity, transform, filter, will-change and z-index + position all seal a context. Use isolation: isolate, a z-scale, and portals or <dialog>.
📱
Units first, breakpoints lastrem text, ch line length, dvh on phones, clamp() for fluid sizes, container queries for components.
If you want…Write
Centre anythingdisplay: 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 rightmargin-left: auto on it, inside a flex row
Equal columnsdisplay: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
Responsive cards, no media queriesgrid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
Sidebar + contentdisplay: flex with .side { flex: none; width: 240px } and .main { flex: 1; min-width: 0 }
Badge on a cornerparent position: relative, badge position: absolute; top: 0; right: 0
Overlay over a boxparent position: relative, overlay position: absolute; inset: 0
Stop margin leaking out of a parentdisplay: flow-root on the parent
Keep a component's z-index inside itisolation: isolate
One-line text with …white-space: nowrap; overflow: hidden; text-overflow: ellipsis; (+ min-width: 0 on the flex item)
Full-height mobile sectionmin-height: 100dvh
Readable paragraph widthmax-width: 65ch
Fluid headingfont-size: clamp(1.5rem, 1rem + 2vw, 2.5rem)
Works in right-to-left toomargin-inline-start, padding-inline, inset-inline-end, text-align: start
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Frontend & BrowserDOM Events
Read →