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.
Before I reach for flexbox, what is the browser doing by default — and where does the content go when it does not fit?
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.
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.
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.
- 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-topon 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: hiddento 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: stickyinside it stops working against the page, and content is clipped with no way to reach it.display: flexon 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: visibleis still painted and still focusable. It is not gone — it is on top of something else, and it will be clicked.
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,overflowother thanvisible, 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.
overflowwith any value other thanvisible(andclip) 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 forposition: stickydescendants (Positioning and Stacking Contexts).overflow: clipclips 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: nonewhen 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".
.section {
overflow: hidden; /* "stop the margin escaping" */
}.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 | gapThe 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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
overflow: hidden on a card with a focusable link inside | Tabbing scrolls the card sideways and the layout looks broken | Focus makes the browser scroll the nearest scroll container to reveal the target — and hidden is one | Use clip if it must be cut off, or fix the size so nothing overflows (Focus Management). |
A wide table wrapped in overflow-x: auto | Keyboard users cannot scroll to the far columns | The wrapper is a <div>: not focusable, no accessible name | Give the wrapper tabindex="0", role="region" and an aria-label naming the table (Keyboard Operability). |
| A scrollable panel inside a scrollable page | A wheel gesture escapes the panel and scrolls the page behind it | Scroll chaining: the panel reaches its end and passes the gesture up | overscroll-behavior: contain on the panel, and consider whether nested scrolling was the right shape at all. |
| Content inserted above the current scroll position | The list jumps while the user is reading it | Scroll anchoring is compensating, or failing to | Reserve the space before inserting; use overflow-anchor: none only on the smallest container that fixes it (Visual Stability). |
height: 100vh on a phone | The bottom of the layout sits under the browser toolbar | vh is the large viewport, which is not what is visible while the toolbar is shown | Use dvh / svh deliberately per case (The Viewport and Device Pixels). |
| Value | Clips? | Scroll container? | Scrollbars | The trap |
|---|---|---|---|---|
visible (initial) | No | No | None | Overflowing content paints over its neighbours and still receives clicks |
hidden | Yes | Yes | Suppressed | Script and focus can still scroll it, so hidden content appears at the worst moment |
clip | Yes | No | None | Truly unreachable — correct when you mean it, wrong when the content mattered |
scroll | Yes | Yes | Always shown | Reserves gutter space even when nothing overflows; on overlay-scrollbar platforms it does not |
auto | Yes | Yes | When needed | Appearing 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.
Two sibling components need 24px between them. Who declares it?
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).
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.
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).
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 asgapon 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, nooverflowside effects. - Use
overflow: clipwhen you mean clipping andoverflow: autowhen you mean scrolling.hiddenis 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 viarole="region"andaria-label, so a screen-reader user is told what they landed in. - Clipped content is still in the accessibility tree.
overflow: hiddenon 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: noneto 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
- Margin collapsing through an empty wrapper, so a spacing bug moves when an unrelated element gains a border.
overflow: hiddenadded 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: 100vhon 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).
- 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: stableexists 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.
- Overflowing content under
overflow: visibleis 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: hiddenis 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.
- "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: hiddenjust 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
- In the Elements panel, a
scrollbadge 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.clientHeightis 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).
- 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: containchanges 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).
display: flow-rootis the precise tool and it is one more concept to teach;overflow: hiddenis the widely-known one that also does four other things. Precision costs familiarity.- Abandoning margins for
gapremoves 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
tabindexleaves some users unable to reach the content.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — 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.