Why it's so hard to get a child element to scroll in flexbox

min-height is breaking your flexbox layout

You’ve built the classic app shell. Top bar across the top, sidebar down the left, and a big content pane. You give the content pane overflow-auto, so that it scrolls instead of the whole page scrolling away your layout.

And yet…the whole page still scrolls. The top bar scrolls right off the screen and the content pane’s scrollbar never shows up even though every element in the chain has height: 100%.

It turns out that by default, every flex item in a column layout sneakily has

min-height: auto;

For the purposes of our layout, this effectively means

“

I will not become shorter than my content’s intrinsic height, no matter what the flex algorithm says.

<style>
html,
body {
height: 100%;
}
</style>
<div data-name="shell" x class="flex h-full flex-col">
<header>top bar</header>
<div data-name="row" class="flex flex-1">
<nav>sidebar</nav>
<main class="h-full overflow-auto">700px of table</main>
</div>
</div>
top bar
row 1
row 2
row 3
row 4
row 5
row 6
row 7
row 8
row 9
row 10
row 11
row 12
row 13
row 14
without min-h-0 - the whole page scrolls
  1. The shell is height: 100%, viewport height.

  2. The below header flex-1 which contains flex-grow: 1, so the flex algorithm wants to size it vertically to “viewport minus top bar”.

  3. But because main (and thus row) is taller than the viewport, there is a conflict:

    • The implicit min-height: auto of row means that its minimum height is its content height, which in this case is the 700px tall main
    • The flex-grow + flex-basis of row should make it fit perfectly below header and next to sidebar by growing to fit the space.
  4. min-height wins the argument against flex-basis

    • row grows until its height fits main
    • main’s height: 100% resolves against row that already grew to fit the content1, so “100%” is big enough to hold everything.
    • main’s overflow-auto never fires
  5. This all results in the shell overflowing the viewport and the browser scrolling the entire layout away.

<div data-name="row" class="flex flex-1 min-h-0"></div>

min-h-0 replaces min-height: auto with min-height: 0, telling the row “you’re allowed to be smaller than your content.”

That caps the row’s height at the remaining viewport height, and every height below it snaps into place. main’s h-full now means “row height” instead of “content height”, and the overflow lands where we already handled it: main’s overflow-auto scroll container.

top bar
row 1
row 2
row 3
row 4
row 5
row 6
row 7
row 8
row 9
row 10
row 11
row 12
row 13
row 14
without min-h-0 - the whole page scrolls
top bar
row 1
row 2
row 3
row 4
row 5
row 6
row 7
row 8
row 9
row 10
row 11
row 12
row 13
row 14
with min-h-0 - the table scrolls


That whole example was about a child of a flex column, but the same is true regardless of flex-direction.

In a flex-direction: row layout the same default shows up as min-width: auto, and its often resulting in text truncation. If you’ve ever stacked truncate on a flex child and watched it stubbornly not truncate while blowing out the layout: it’s this exact bug rotated 90 degrees. min-width: 0 is the fix.

You often need a panel that grows to fill available space, but stops growing once it runs out of content, and scrolls only when it has to.

flex: 1;
min-height: 0;
max-height: max-content;
overflow-y: auto;

max-height: max-content caps the panel at “as tall as my content wants to be”, so it never greedily hoards empty space. It takes room while it needs it, stops when it doesn’t, and scrolls when it can’t get enough.

“It doesn’t want to scroll, but it will if it has to.”

Any time an height: 100% / overflow: auto chain passes through a flex item, that item needs min-height: 0 (or overflow: hidden).


  1. This is cyclical because the content that the row grows to fit is main, and the parent that main’s height: 100% is growing to fit is the row. ↩

  2. Per the flexbox spec, the auto minimum only resolves to the content-based minimum when overflow is visible; otherwise it’s zero. ↩