On This Page
The div that won't fill its parent
I hit this one on an internal admin tool: a settings modal with a header, a scrollable body, and a footer. The modal itself had max-height: 80vh so it wouldn't blow past the viewport on short screens. The body had height: 100% so it would fill whatever space was left. Simple enough. Except the body rendered exactly one line tall, no matter how much taller the modal got.
Here's the reduced version, stripped down to the two rules that matter:
css
.modal {
max-height: 300px;
width: 200px;
overflow: auto;
border: 2px solid navy;
}
.modal-body {
height: 100%;
background: lightyellow;
}html
<div class="modal">
<div class="modal-body">one line of content</div>
</div>Open that in any browser and .modal-body is exactly as tall as its text. Not 300px. Not anywhere close. I measured it in Chromium: the modal itself sits at 24px (content plus a 2px border on each side), and .modal-body's computed height comes back as 20px: the height of the single content line, not a percentage of anything.
Open DevTools and it gets weirder, in the "everything looks correct" way that wastes the most time. The Styles pane shows height: 100% on .modal-body, not struck through, not overridden, nothing crossed out. It's winning the cascade. Flip to the Computed panel, tick "Show all," and the resolved value sitting next to height is a small pixel number that has nothing to do with any percentage math you'd expect. That mismatch (a cleanly winning declaration next to a computed value that ignores it) is the signature of this whole family of bugs, and it's worth learning to recognize on sight.
A four-line file that fails the same way in every engine
You don't need the modal to see it. This is the entire bug:
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
.parent { width: 300px; border: 4px solid tomato; }
.child { height: 100%; background: lightblue; }
</style>
</head>
<body>
<div class="parent"><div class="child">child</div></div>
</body>
</html>Save that, open it, and .child is exactly as tall as the word "child." Now add one line that gives .parent an actual height:
css
.parent { width: 300px; height: 300px; border: 4px solid tomato; }and .child jumps to 292px (300px minus the 4px border on top and bottom, since we're using border-box). Nothing else changed. That's the whole mechanism in one edit.
If you're reaching for a live sandbox instead of a local file: CodePen, StackBlitz, or a plain python3 -m http.server in a scratch folder all reproduce this identically, no build step, no bundler, no framework required. If you're on Tailwind, the same bug shows up as h-full doing nothing. h-full compiles straight to height: 100%, and Tailwind's own GitHub discussions (tailwindlabs/tailwindcss#12358, among others) are full of people hitting exactly this, usually inside a card or modal component where a parent has max-h-* but no real h-*.
Is this a browser difference? No.
I want to be upfront that this isn't a compatibility story. There's no vendor prefix, no @supports fallback, no version number to check. Percentage-height resolution against the containing block is specified in CSS2's visual formatting model and has behaved identically across Chrome, Firefox, and Safari since before any of them had their current names. I ran the repros above through Chromium directly (Playwright, headless) to get the exact pixel numbers quoted in this piece; I didn't independently re-run every one in Firefox and Safari, but this isn't the kind of behavior that varies: it's core box-model math, not a rendering quirk, and the same CSS2.1 §10.5 rule is what every engine implements.
What has changed is which tools you reach for around it. Flexbox's flex: 1 and Grid's default align-items: stretch sidestep the whole percentage-chain problem for their own children (more on that below). Both have been safe to use everywhere for years. Viewport units (svh/lvh/dvh) solve a related but distinct problem, the "100vh is taller than the visible area on mobile Safari" bug, and if that's what brought you here, that's a separate piece; this one is about percentage heights failing even on desktop, with no viewport or mobile browser involved at all.
One honest gap: neither Chrome's nor Firefox's "inactive CSS" indicator (the feature that grays out a declaration and tells you why it does nothing) flags this particular case. I checked both tools' own documentation before writing this, and neither lists ancestor-height resolution among what they detect. Which makes sense: catching it means walking the whole ancestor chain, not just inspecting one element in isolation, and that's a different (and more expensive) kind of check than what those indicators do today. So you're on your own for this one, which is exactly why the Computed panel technique below matters.
Why the percentage has nothing to measure against
Percentages aren't absolute. height: 100% doesn't mean "be tall." It means "be as tall as some other box, times one." The question is always: which box, and does that box actually have a height yet?
The box in question is the containing block, normally the padding box of the nearest ancestor, though the exact rules differ for statically positioned versus absolutely positioned elements. If that ancestor's own height comes from its content (the default: an ordinary block box is exactly as tall as what's inside it), then asking a child to be "100% of the parent" is asking the parent to be defined by the child while the child is defined by the parent. Nothing breaks the loop. So the spec doesn't try to solve it. It just declares the percentage void.
How the containing block actually resolves a percentage
Here's the CSS2.1 rule, and it's worth reading slowly because most explanations paraphrase it into something vaguer than it is. MDN's formal definition for height states it exactly:
The percentage is calculated with respect to the height of the generated box's containing block. If the height of the containing block is not specified explicitly (i.e., it depends on content height), and this element is not absolutely positioned, the value computes to auto.Two words carry the whole bug: "specified explicitly." Your .modal in the earlier example has max-height: 300px (a real, explicit rule), but max-height isn't height. It's a ceiling on the used height, not a specified height in the sense this rule means. As far as the percentage-resolution algorithm is concerned, .modal's height is still auto, still derived from content, max-height or no max-height. I checked this directly rather than assuming it: a .modal with max-height: 300px and one short line of content renders at 24px, not 300px, and its 100%-height child renders at 20px, matching the content exactly. max-height caps growth. It does not, by itself, give anything downstream a number to divide by.
There's a second wrinkle almost nobody mentions: height: 100% and min-height: 100% don't fail the same way. MDN's formal definition for min-height is explicit that when the containing block's height isn't specified, "the percentage value is treated as 0" — not auto. Practically, on an empty box, auto and 0 render identically, so you'd never notice from the screenshot. But if you're debugging by swapping properties to see what "sticks," you're comparing two different computed values that happen to produce the same wrong pixel count, which is a good way to convince yourself you've ruled something out when you haven't.
And the root element gets its own carve-out: a percentage height on <html> resolves against the initial containing block (effectively the viewport), which is the entire reason the old html, body { height: 100% } incantation works at all. <html> is the one element in the tree that gets to cheat and measure against the viewport instead of a parent's content-derived height. Every element below it is back to playing by the normal rules, which is why the chain has to be unbroken all the way down: skip one link, and everything under that link is back to auto.
Flexbox and Grid don't remove this rule. They route around it. Once a flex or grid container has a definite height (from a length, a vh unit, or its own resolved percentage chain), its items get sized by flex distribution or track sizing, not by asking "what's 100% of my parent." That's a completely different algorithm, and it only needs one ancestor in the chain to have a real height, not every ancestor above it.
Chasing a bug that looked like a specificity bug
Two wrong turns, in the order I actually took them.
First, I assumed something was overriding .modal-body's height: a utility class, a reset, a higher-specificity selector from a component library loaded after mine. That's the default assumption for "my CSS isn't doing what I wrote," and it's usually right. I opened the Styles pane expecting to find my rule crossed out. It wasn't. height: 100% sat there, fully active, nothing above it in the cascade contesting it. Fifteen minutes gone confirming a theory that DevTools disproved in about four seconds, if I'd checked it first instead of last.
Second, I tried min-height: 100% instead, on the theory that min-height is "more forgiving" than height and might at least give the box a floor. Same collapse. I know now this is technically a different computed value (0 instead of auto, per the MDN wording above) landing on the same visible result, which is exactly the kind of near-miss that makes you think you've eliminated a variable when you've only swapped one wrong value for a different wrong value.
The tell, once I stopped guessing and started reading the Computed panel properly: the resolved pixel number for height matched the content's natural size to the pixel: 20px, exactly one line of text at that font size. Not a fraction of 300px, not a fraction of anything. A percentage that had actually resolved against a real number would produce some value tied to that number. A percentage that's silently computing to auto produces the same number you'd get with no height declaration at all. If your "broken" pixel value equals what the element would be with the declaration deleted, the declaration isn't being overridden. It's being ignored at the resolution stage, and you should stop looking at specificity entirely.
To find where the chain breaks on your own page, walk the ancestors from the console instead of clicking through DevTools one level at a time:
js
// Walk up from the selected element and print each ancestor's
// height, display, and position, so you can see exactly which
// one first fails to have a real, specified height.
let el = $0, chain = [];
while (el) {
const cs = getComputedStyle(el);
chain.push({
tag: el.tagName + (el.className ? '.' + [...el.classList].join('.') : ''),
height: cs.height,
inlineHeight: el.style.height || '(stylesheet)',
display: cs.display,
position: cs.position,
});
el = el.parentElement;
}
console.table(chain);Run it with the broken element selected in the Elements panel (so $0 refers to it), and read down the table from the bottom. The first ancestor whose height isn't a real, deliberately-set number, where it's just whatever the content added up to, is the break in the chain. Everything below that ancestor is inheriting nothing to measure against.
Ranked by how often each one is actually the cause, worst offender first:
- A missing link in the chain: one ancestor between the element and
<html>has no explicit height, so everything below it resolves toauto. This is what got me, andmax-heightstanding in forheightis the single most common disguised version of it. html, bodynever got their own height set, so even a perfect chain further down has nothing to anchor to at the top. The classic beginner version.min-height: 100%used as a "safer" substitute forheight: 100%: same root cause, different computed value, identical visible failure.height: 100%on a flex or grid item, with the container itself lacking a definite height: the mental model gets muddled here because flex/grid does solve this class of problem, just not for free; the container still needs a real height first.- The absolutely-positioned "escape hatch" myth: people assume
position: absolutelets a percentage height bypass the containing block rule entirely. It doesn't. If the positioned ancestor's own height is alsoauto, the percentage still has nothing to resolve against and renders as zero, not as content-basedauto. I confirmed this directly: an absolutely positioned child withheight: 100%inside aposition: relativewrapper that has no content of its own collapses to 0px, full stop.
Fixing it: give the parent a real height first
The direct fix for the modal is one line, and it's the fastest way to see the mechanism prove itself:
diff
.modal {
max-height: 300px;
+ height: 300px;
width: 200px;
overflow: auto;
border: 2px solid navy;
}That works. I don't love it, though, because it throws away exactly the behavior max-height was there for: a modal that shrinks to fit two lines of content and only caps out at 300px when there's more than that. Forcing height: 300px makes it always 300px tall, empty or full, which is worse UI than the bug it fixes.
Ranked, best first, for what I'd actually ship:
Switch the parent to flexbox and let the child fill remaining space instead of asking for a percentage:
diff
.modal {
max-height: 300px;
width: 200px;
- overflow: auto;
border: 2px solid navy;
+ display: flex;
+ flex-direction: column;
}
.modal-header { flex: 0 0 auto; }
.modal-body {
- height: 100%;
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
}I tested this end to end: with a single short line of content the modal sits at 64px (header plus that one line, no forced padding), and with forty lines of content it caps at exactly 300px with the body scrolling internally: scrollHeight of 800px inside a 256px box, exactly the two-mode behavior the original design wanted. The min-height: 0 isn't optional set-dressing; it's the same automatic-minimum-size rule that makes flex children refuse to shrink below their content by default (if you've hit text-overflow: ellipsis doing nothing in a flex row, it's the same underlying rule, just on the other axis).
If you can't touch the markup structure, complete the chain deliberately. Set a real height (not a cap) on every ancestor between the target and the nearest box that already has one, using height: 100% the whole way if that ancestor is meant to track something above it, or a fixed/viewport unit if it's meant to be the anchor.
If the anchor is the viewport, use a viewport unit directly instead of chaining percentages through html and body: height: 100dvh on the top-level container does in one declaration what html, body { height: 100%; } used to take three rules and two elements to achieve — though as covered elsewhere, dvh brings its own mobile-Safari toolbar-resize behavior worth knowing before you rely on it.
What I wouldn't do: reach for !important, or wrap the child in position: absolute; inset: 0; as a blanket fix. The absolute-positioning trick can work, but only if the positioned ancestor's own height is already resolved by something else in normal flow. It's borrowing a different rule (offsets stretching an absolutely positioned box that has no explicit height of its own), not opting out of containing-block math, and reaching for it without understanding that just relocates the same bug one level up.
Stop chaining height: 100% up the DOM
The version of this I'd put in a design system doesn't use percentage heights at all for "fill the available space." It uses Flexbox or Grid for exactly what they're for: sizing children relative to whatever room a container actually has, computed by an algorithm that only needs one definite height at the top of the layout, not a percentage relay race down every intermediate box.
A page shell looks like this, and it never touches a single percentage:
css
html, body { height: 100%; } /* still fine: this is the one legitimate use of the chain */
#app { height: 100dvh; display: flex; flex-direction: column; }
#app > main { flex: 1; min-height: 0; overflow: auto; }One definite height at the very top (100dvh on #app), and every layer below it fills space by flex distribution, not by asking a parent "what's your height, so I can take 100% of it." Add a sidebar, a footer, a nested scrollable panel. None of it needs its own height: 100%, because none of it is measuring a percentage against anything. It's just given a share of space that already exists.
Grid earns its keep here too: a single-row display: grid container with a definite height stretches its items to fill that row by default (align-items: stretch), no height declaration on the item required at all. I tested a two-row grid (header row fixed at 50px, content row at 1fr) and the content cell filled the remaining space automatically, computed value and all, with nothing written on the child itself.
Guardrails so this doesn't come back
A stylelint rule won't catch this. It requires reasoning across the ancestor tree, which is exactly the kind of cross-file, cross-selector check a linter that operates rule-by-rule can't do. So the guardrail here isn't static analysis. It's a rendering check.
A Playwright visual-regression test that renders your scrollable panels, modals, and sidebar layouts at a couple of realistic content lengths (empty, one line, overflowing) catches this class of bug immediately, because the symptom is visual and layout-shaped, not something a type-checker or a CSS parser will ever flag. If a panel that should be 300px tall renders at 20px in the screenshot diff, you'll see it before a reviewer has to eyeball it.
Two smaller habits pay for themselves. First, when you write max-height (or min-height) on a container that another element is going to size itself against, ask explicitly whether that container also needs a specified height: the two properties are not interchangeable for this purpose, and nothing will warn you that they aren't. Second, prefer Flexbox/Grid for "fill available space" from the start on any new component; it's not just cleaner, it structurally can't produce this bug, because it never asks a percentage question that content-driven sizing can't answer.
What to remember about height: 100%
A percentage height needs an ancestor with a genuinely specified height (not a cap, not a minimum, an actual height value), reachable by an unbroken chain back to something already definite, ultimately the viewport.
max-height and min-height do not count as "specified" for this purpose, even though they read like they should.
height: 100% and min-height: 100% fail identically to the eye but compute to different values (auto versus 0). Don't let a swap between them convince you that you've isolated the cause.
Absolute positioning is not an escape hatch from this rule; it only helps once the positioned ancestor's own height is already resolved some other way.
Flexbox and Grid don't repeal the containing-block rule. They replace the whole percentage relay with an algorithm that needs exactly one definite height upstream, which is why they're the fix worth reaching for by default, not just for this one bug.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.