I found this while taking screenshots for a Product Hunt listing, which is an embarrassing way to discover that the top of your own marketing site has been missing.
The page opened on its second section. The headline, the subheading, the platform badges, the animated terminal and the single call to action button were all absent. Not mispositioned. Absent.
Everything I would normally check said it was fine
This is the part worth writing down, because every instinct I reached for came back green.
curl returned the headline. It is right there in the served HTML:
curl -s https://example.dev | grep -o "Your Next Stack, Sparked"
# Your Next Stack, Sparked
The element existed in the DOM. It had real dimensions:
document.querySelector('h1').getBoundingClientRect()
// { x: 83, y: 336.9, width: 536, height: 48.4 }
No console errors. No failed requests. No hydration warning. A 536x48 heading, sitting at y=337, which is on screen.
So the markup was right, the element was right, the element had a size, and the element was invisible.
The measurement that actually found it
I stopped checking whether things existed and started measuring what the layout was doing. Specifically, the height of every direct child of the scroll container:
const sc = document.querySelector('div.flex-1.min-h-0.overflow-y-auto');
[...sc.children].map(e => ({
tag: e.tagName,
offsetTop: e.offsetTop,
height: Math.round(e.getBoundingClientRect().height),
text: e.textContent.trim().slice(0, 30),
}));
Which gave me this:
| element | offsetTop | height |
|---|---|---|
hero <section>
|
57 | 0 |
demo video <section>
|
57 | 654 |
features <section>
|
711 | 919 |
Two sections claiming the same offsetTop. The hero was zero pixels tall, so the next section laid out on top of it, and the hero's own overflow-hidden clipped its contents out of existence.
The h1 genuinely was 536x48. It was just 536x48 inside a box with no height, painted underneath the section that came after it.
Why it collapsed
The page shell is one viewport tall and never scrolls itself. A fixed-height flex column holds everything, and one inner div does the scrolling:
<div class="h-dvh overflow-hidden flex flex-col">
<nav class="shrink-0">...</nav>
<div class="flex-1 min-h-0 overflow-y-auto flex flex-col">
<section class="relative overflow-hidden">...</section> <!-- the hero -->
<section>...</section>
<section>...</section>
</div>
</div>
Every section is a flex item in a fixed-height column, so every section is shrinkable. flex-shrink defaults to 1.
Normally that does not matter, because a flex item's automatic minimum size (min-height: auto) stops it shrinking below its content. That default is what keeps flex columns from crushing themselves.
But min-height: auto only applies when the item's overflow is visible. From the CSS Flexible Box spec: the automatic minimum size is zero when the box is a scroll container, and overflow: hidden makes it one.
The hero was the only section with overflow-hidden on it. So it was the only section whose minimum size was zero. Every other section held its ground, the column still needed to give up space, and the one item that could shrink to nothing did exactly that.
The overflow-hidden was there to clip a decorative background. It was not doing anything I would have thought of as layout.
Proving it before changing anything
Two one line experiments in the console on the live page, before I touched the source:
const hero = document.querySelector('div.flex-1.min-h-0.overflow-y-auto > section:first-child');
const h = () => Math.round(hero.getBoundingClientRect().height);
h(); // 0
hero.style.flexShrink = '0'; h(); // 716
hero.style.flexShrink = '';
hero.style.minHeight = 'auto'; h(); // 0
flex-shrink: 0 brings it back. min-height: auto does not, which is the tell: the automatic minimum is already being computed as zero because of the overflow, so restating it changes nothing. That second result is what turns a plausible theory into the actual mechanism.
The fix
One class.
- <section className="relative overflow-hidden">
+ <section className="relative shrink-0 overflow-hidden">
I left a comment on it, because the next person to tidy up a "redundant" utility class will delete it:
shrink-0 is load-bearing. This section is a flex item in the fixed-height
flex column above, so it is shrinkable by default. Every other section keeps
min-height: auto and resists, but overflow-hidden forces this one's automatic
minimum size to zero, so it absorbed the whole overflow and collapsed to 0px.
Do not remove without also removing overflow-hidden.
How to check your own
If you have a fixed-height flex column anywhere, measure the children rather than trusting that the content is present:
[...document.querySelectorAll('section, main > div')]
.map(e => [e.className.slice(0, 40), Math.round(e.getBoundingClientRect().height)])
.filter(([, h]) => h === 0);
Anything returning zero while containing real content is this bug. The combination to look for is a flex or grid item that sets overflow to anything other than visible, inside a container with a constrained height.
Worth saying plainly: none of my usual checks could have caught this. Server rendered HTML tests pass, because the HTML is correct. Snapshot tests pass, because the tree is correct. A smoke test asserting the headline is in the document passes. The only thing that catches it is asking the browser how tall the box actually is.
Disclosure
The site is SparkStack, a Next.js starter I sell, so this was my own shop window with the lights off. I do not know exactly how long it was broken, which is its own lesson: I had no check that would have told me. The fix is deployed and the hero has been back at 716 pixels since.
If you take one thing from this, make it the measurement rather than the fix. getBoundingClientRect().height on your layout wrappers costs nothing and tells you something that reading the markup cannot.
Top comments (0)