LayoutGENERALBROWSER-SPECIFIC

Normal Flow, Overflow and Margin Collapsing

What the browser already does before you choose a layout mode: block and inline formatting, margins that merge, and the moment a box becomes a scroll container.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Before I reach for flexbox, what is the browser doing by default — and where does the content go when it does not fit?

The user intent

Someone is writing a page of content: headings, paragraphs, a list, an image. They want it to read top to bottom with sensible spacing, and they want long content to stay reachable.

The obvious build

Normal flow is the thing you replace. Put display: flex on everything, give every element a margin, and set overflow: hidden on any container that leaks.

Why it breaks

The margins between two paragraphs do not add up. A 24px bottom margin above a 16px top margin produces 24px of space, not 40px, and the spacing scale you designed silently stops being a scale.

How it breaks in a real browser
  • The margins between two paragraphs do not add up. A 24px bottom margin above a 16px top margin produces 24px of space, not 40px, and the spacing scale you designed silently stops being a scale.
  • A margin-top on the first child escapes the parent entirely and pushes the *parent* down, so the coloured section starts 32px lower than the markup suggests and adding padding to the parent "fixes" it for reasons nobody writes down.
  • overflow: hidden to stop a leak turns the element into a scroll container and a block formatting context at the same time. Now margins no longer collapse through it, position: sticky inside it stops working against the page, and content is clipped with no way to reach it.
  • display: flex on a text container destroys inline layout: anonymous text runs each become their own flex item, so a paragraph with a <strong> in it wraps in ways that no line-breaking rule explains.
  • Content that overflows an element with the default overflow: visible is still painted and still focusable. It is not gone — it is on top of something else, and it will be clicked.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Normal flow has two participants. Block-level boxes fill the inline axis of their containing block and stack one after another along the block axis. Inline-level boxes flow along the inline axis, wrapping into line boxes when they run out of room.
  • A box establishes a formatting context — the rules its children are laid out under. A block formatting context (BFC) lays out block children; an inline formatting context lays out line boxes. Flex and grid containers establish their own contexts, which is precisely why their children stop behaving like flow children.
  • Adjoining vertical margins collapse into one, taking the largest value. Three cases: between siblings, between a parent and its first or last in-flow child, and through an empty block with no height, padding or border.
  • Collapsing stops at a boundary. A new BFC, a border, padding, display: flow-root, overflow other than visible, absolute positioning, or being a flex or grid item — any of these prevents the margin from passing through. `gap` never collapses, which is most of why it replaced margins for spacing.
  • Horizontal margins never collapse. Neither do margins in a flex or grid container. Collapsing is a block-flow behaviour designed for prose, and it is well suited to prose.
  • overflow with any value other than visible (and clip) makes the box a scroll container: it gets a scrollport, it can be scrolled programmatically even when scrollbars are hidden, and it becomes the containing block for position: sticky descendants (Positioning and Stacking Contexts).
  • overflow: clip clips without creating a scroll container at all — no scrollport, no programmatic scrolling, no accidental scroll capture. It is the honest choice when you truly meant "cut this off".

What this makes the browser do

And which of it is avoidable.

  • Block layout is close to a single pass down the tree: each box's inline size comes from its containing block, and its block size from its children. This is why flow layout is cheap relative to the alternatives.
  • Inline layout is line breaking: measure runs of text, find break opportunities, build line boxes. Its cost scales with text volume and with how many times the available width changes (Intrinsic Sizing and the Automatic Minimum).
  • Every scroll container is bookkeeping the browser must maintain: a scroll offset, an overflow rect, and a candidate for scroll anchoring. Hundreds of them is a real cost, not a notional one.
  • Scroll anchoring silently adjusts scroll offset when content above the viewport changes size, so the user does not lose their place. It is the browser doing layout work you did not ask for, and it can be disabled with overflow-anchor: none when it fights an infinite list.

Two flows, one page

Normal flow is not one algorithm but two, and knowing which one you are inside answers most "why did it do that" questions. Block layout stacks boxes along the block axis, each filling the inline axis. Inline layout fills lines along the inline axis and wraps them into line boxes stacked along the block axis.

A box establishes a formatting context for its children. Once you set display: flex or display: grid, its children stop being flow children — their margins no longer collapse, float no longer applies to them, and raw text becomes an anonymous item rather than a run inside a line box. That is a much bigger change than "the children are in a row now".

Containing a child's margin
Side effects
.section {
  overflow: hidden; /* "stop the margin escaping" */
}
Exactly the one effect
.section {
  display: flow-root;
}

/* or remove the question entirely */
.stack { display: grid; gap: 1.5rem; }

overflow: hidden creates a block formatting context *and* a scroll container: it clips focusable descendants, breaks position: sticky against the page, and lets script scroll a box the user cannot. flow-root creates the formatting context and nothing else, and gap removes the collapsing question rather than suppressing it.

BLOCK FORMATTING CONTEXT                    INLINE FORMATTING CONTEXT
(children stack in the block axis)          (children flow in the inline axis)

+----------------------------------+        +----------------------------------+
| <h2>  fills the inline axis      |        | line box 1: The quick brown fox  |
+----------------------------------+        | line box 2: jumps over the lazy  |
  margin-block-end: 24px  --.                | line box 3: dog. |<strong>|end.  |
+----------------------------------+ <-'    +----------------------------------+
| <p>   fills the inline axis      |          ^                ^
+----------------------------------+          |                |
  margin-block-end: 16px  --.                  |                inline box, no
+----------------------------------+ <-'      line boxes,       width of its own:
| <p>                              |          stacked in        it is a run, not
+----------------------------------+          the block axis    a block

between the two paragraphs: 24px and 16px are ADJOINING -> they collapse to 24px

  what stops the collapse:  a border | padding | display: flow-root
                            overflow != visible | flex or grid item | gap

The moment a box starts scrolling

Overflow is not one decision but two: whether content is clipped, and whether the clipped part remains reachable. The four values answer those two questions in every combination, and choosing by habit rather than by intent is how a page ends up with content nobody can get to.

The important asymmetry: hidden and clip look identical on screen and are completely different structurally. hidden is a scroll container whose scrollbars are suppressed — script can scroll it, the browser will scroll it to reveal a focused child, and it anchors sticky descendants. clip is not a scroll container at all.

Scroll containers in the wild
TriggerSymptomCauseResponse
overflow: hidden on a card with a focusable link insideTabbing scrolls the card sideways and the layout looks brokenFocus makes the browser scroll the nearest scroll container to reveal the target — and hidden is oneUse clip if it must be cut off, or fix the size so nothing overflows (Focus Management).
A wide table wrapped in overflow-x: autoKeyboard users cannot scroll to the far columnsThe wrapper is a <div>: not focusable, no accessible nameGive the wrapper tabindex="0", role="region" and an aria-label naming the table (Keyboard Operability).
A scrollable panel inside a scrollable pageA wheel gesture escapes the panel and scrolls the page behind itScroll chaining: the panel reaches its end and passes the gesture upoverscroll-behavior: contain on the panel, and consider whether nested scrolling was the right shape at all.
Content inserted above the current scroll positionThe list jumps while the user is reading itScroll anchoring is compensating, or failing toReserve the space before inserting; use overflow-anchor: none only on the smallest container that fixes it (Visual Stability).
height: 100vh on a phoneThe bottom of the layout sits under the browser toolbarvh is the large viewport, which is not what is visible while the toolbar is shownUse dvh / svh deliberately per case (The Viewport and Device Pixels).
ValueClips?Scroll container?ScrollbarsThe trap
visible (initial)NoNoNoneOverflowing content paints over its neighbours and still receives clicks
hiddenYesYesSuppressedScript and focus can still scroll it, so hidden content appears at the worst moment
clipYesNoNoneTruly unreachable — correct when you mean it, wrong when the content mattered
scrollYesYesAlways shownReserves gutter space even when nothing overflows; on overlay-scrollbar platforms it does not
autoYesYesWhen neededAppearing scrollbars change the content box, which can re-break lines and change whether they were needed

Spacing as a single rule

Margin collapsing is not a bug, and it is not worth fighting property by property. It exists so that a document made of independently-authored blocks produces even spacing without anyone summing margins — which is exactly right for prose and exactly wrong for a component system where each component owns its own edges.

The durable fix is architectural rather than per-element: decide once where spacing lives. Either the container owns it via gap, or children own exactly one edge each. Both remove collapsing from the conversation; mixing them guarantees it comes back.

Where does the space between two components live?

Two sibling components need 24px between them. Who declares it?

The container, via `gap`

when The siblings are laid out by a flex or grid container you control — which is most component layout

cost Requires a container element for every spaced group, adding DOM nodes for a visual reason (Div Soup: How It Happens and What It Costs).

One edge per child (`margin-block-end` only)

when The children are in normal flow and you cannot add a container — a rich-text or CMS-rendered region

cost Still collapses against a parent's margin, and needs a :last-child rule to strip the trailing edge.

A flow utility (`.stack > * + * { margin-block-start: 24px }`)

when Arbitrary, unknown children in flow, and you want spacing between but never around

cost A selector that matches on every style recalculation of the subtree, and one more abstraction to learn (Selector Matching Cost).

Padding on the parent

when The space is *inside* a boundary — a card's inner padding, not the distance between cards

cost Wrong tool for between-sibling spacing: it does not scale with the number of siblings and it paints the parent's background.

How to build it

Most important first.

  • Let flow do the work it is good at. Prose, headings, lists and figures are what block and inline layout were designed for; a flex container per paragraph buys nothing and costs line-breaking behaviour.
  • Pick one spacing direction and hold it. A single-direction rule — spacing only ever as margin-block-end, or better as gap on the container — removes collapsing from the discussion entirely.
  • When you need to contain a child's margins, say so with display: flow-root. It creates a BFC and nothing else: no clipping, no scroll container, no overflow side effects.
  • Use overflow: clip when you mean clipping and overflow: auto when you mean scrolling. hidden is the one that means both and surprises people later.
  • Give every scroll container a way in from the keyboard, and make sure it is discoverable. A <div> that scrolls is invisible to a keyboard user unless it is focusable or contains something focusable.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A scroll container that is not focusable and contains no focusable content is unreachable by keyboard. Firefox and Chrome now make such regions focusable by default, but the reliable fix is tabindex="0" plus an accessible name via role="region" and aria-label, so a screen-reader user is told what they landed in.
  • Clipped content is still in the accessibility tree. overflow: hidden on a container with focusable children produces the worst outcome available: a keyboard user tabs to something they cannot see, and the browser scrolls the hidden container to reach it, breaking the visible layout.
  • Normal flow puts DOM order and visual order in the same order for free. That agreement is what keyboard navigation depends on, and every layout mode after this one gives you the tools to break it (Flexbox: One Axis at a Time, Grid: Two Dimensions at Once).
  • Scroll anchoring keeps a screen-magnifier or low-vision user from losing their reading position when content loads above them. Disabling it with overflow-anchor: none to fix a list-jumping bug takes that away — do it on the smallest container that solves the problem, not the document.
  • At 400% zoom, a page in normal flow reflows into one column on its own. That is the baseline the reflow requirement was written around, and it is the reason a content page is usually accessible before anyone works on it.

What can go wrong

Failure modes
  • Margin collapsing through an empty wrapper, so a spacing bug moves when an unrelated element gains a border.
  • overflow: hidden added to fix a horizontal leak, which breaks a sticky sidebar three components away. The relationship is invisible because the two files are unrelated.
  • A scroll container with no keyboard access: content is reachable only by dragging, so a keyboard or switch user cannot read it at all.
  • Nested scroll containers, where a wheel gesture scrolls the wrong one and the user cannot tell which. Scroll chaining is controllable with overscroll-behavior, but only if you know which container is capturing.
  • height: 100vh on a mobile layout, where the viewport unit does not match the visible area once the browser chrome collapses. The dynamic viewport units (dvh, svh, lvh) exist because the naive one was wrong on every phone (The Viewport and Device Pixels).
What can arrive out of order
  • A scrollbar appearing when content grows reduces the inline size of the content box, which can re-break every line — and if that re-breaking removes a line, the scrollbar can disappear again. scrollbar-gutter: stable exists to break that loop.
  • Scroll anchoring adjusts the scroll offset asynchronously after content above the viewport changes size, so a scroll position read immediately after inserting content may be corrected a frame later.
Security
  • Overflowing content under overflow: visible is painted over whatever is beneath it and receives pointer events there. Content you did not size is content an attacker can use to cover a control (Clickjacking and Framing).
  • Clipping is not hiding. Content clipped by overflow: hidden is in the DOM, in the accessibility tree, and readable by any script — so it is not a way to withhold data from the client.
  • A scroll container whose size is driven by untrusted content can be forced into extreme dimensions. Bound the container, not the content.
Misreads
  • "Margins add up." Adjoining vertical margins in flow collapse to the larger of the two. Horizontal ones never collapse, and flex and grid items never collapse at all.
  • "overflow: hidden just hides the overflow." It also creates a block formatting context and a scroll container, which changes margin collapsing, sticky positioning and programmatic scrolling around it.
  • "Flexbox replaced normal flow." Flexbox replaced float-based *page* layout. Prose is still laid out by the inline formatting context, and always will be.
  • "If I cannot see it, it is not there." Clipped content is focusable, announced, and scrollable into view by the browser at the worst possible moment.

Measuring it, and what changes in the field

How you would see this
  • In the Elements panel, a scroll badge marks every scroll container. Turning it on across a page is the quickest way to find the one that is capturing your gesture.
  • el.scrollHeight > el.clientHeight is the direct test for whether content overflows in the block axis — the reliable way to decide whether to show a "more" affordance.
  • The Layout panel and the Computed tab both show resolved margins. Where the computed margin and the visible gap disagree, you are looking at collapsing (Debugging Rendering and Jank).
Slow device, slow network, large data, old tab
  • On a narrow viewport, flow reflows for free and every explicit layout mode needs a decision. This is why mobile-first tends to produce simpler CSS: it starts from the mode that already works.
  • On a touch device, a scroll container inside a scroll container is much harder to operate than it is with a wheel, and overscroll-behavior: contain changes the feel considerably.
  • With a large document, inline layout cost grows with text volume and with how often the available inline size changes — a resize handler that writes a width during a drag re-breaks every line on every frame (Layout Thrashing).
What this costs
  • display: flow-root is the precise tool and it is one more concept to teach; overflow: hidden is the widely-known one that also does four other things. Precision costs familiarity.
  • Abandoning margins for gap removes collapsing but requires a container for every spaced group, which adds elements to the DOM for a purely visual reason (Div Soup: How It Happens and What It Costs).
  • Scroll containers are excellent for bounded regions and terrible as a general overflow answer: every one you create is a place content can hide from someone who does not know to scroll it.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALBlock and inline formatting, the three margin-collapsing cases and the BFC-creating conditions are specified in CSS Display and CSS Box Model and are consistent across engines; this is old, well-tested behaviour.
  • BROWSER-SPECIFICKeyboard focusability of scrollable regions is not uniform: Chrome and Firefox make a scroll container with no focusable children keyboard-focusable, while Safari historically does not, so relying on the default rather than an explicit tabindex leaves some users unable to reach the content.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design — "who owns this spacing" is an ownership question before it is a CSS question, and it is the same question as "who owns this state" in a different costume.