When position: sticky Meets 100vh
Sticky positioning has a well-known quirk: a sticky element is confined to its direct parent. It can travel with the viewport, but it can't break out of its containing block. This becomes a problem when you want a sticky header and a following hero section to collectively fill the viewport height.
The obvious solution—wrapping both in a container with height: 100vh and using flexbox to distribute space—fails immediately. Once you scroll past the hero, the sticky header gets trapped inside that parent container and scrolls away with it.
Why? A position: fixed element ignores its ancestors and positions itself relative to the viewport. But position: sticky is, as Chris Coyier puts it, a "locally scoped fixed." It will stick within its parent's boundaries, no matter what. So any layout that relies on a shared parent for the header and hero is a non-starter.
Why Fixed Heights Are a Trap
A tempting fallback is to give the header an explicit height and then calculate the hero's height with calc(100vh - 150px) or similar. This works at a single viewport size, but it's brittle:
- Header content may wrap or need to grow at different breakpoints to stay legible.
- JavaScript that manipulates header contents will silently break your height assumptions.
- Every layout change forces you to chase new magic numbers across media queries.
Fixed heights fight CSS's core strength—automatic sizing based on content. It's a maintenance burden you don't want.
A Grid-Based Workaround
The trick is to find an element that is naturally 100vh and use CSS Grid to overlay your real content onto it. Here's the setup:
Give the body display: grid. Add an empty spacer element before the header called .above-the-fold-spacer. Give that spacer height: 100vh and span it across two grid rows, anchored to the top. The spacer is purely structural: it should be empty and invisible, serving only to define the grid's row heights.
Now you have two grid rows that together total exactly 100vh. The header and hero are assigned to those rows. Because CSS Grid allows multiple children to occupy the same cell and overlay each other, the tall spacer dictates the combined row height while the visible elements divide the space between themselves.
Use grid-template-rows to control the split. Set the first row to min-content so the header takes only as much space as it needs, leaving the hero to fill the rest:
body {
display: grid;
grid-template-rows: min-content 1fr;
}
.above-the-fold-spacer {
height: 100vh;
grid-column: 1 / -1;
grid-row: 1 / span 2;
}
.header {
position: sticky;
top: 0;
grid-column-start: 1;
grid-row-start: 1;
}
.hero {
grid-column-start: 1;
grid-row-start: 2;
}
The result: a sticky header of arbitrary size above a hero that shrinks and grows with the viewport, with no hard-coded heights.
Caveats and Adjustments
HTML order matters. If the spacer comes after the hero, it will overlay the hero and block interaction. You can fix this by giving the spacer order: -1, z-index: -1, or visibility: hidden.
The approach generalizes beyond two elements. If you need three items to fill the viewport, make the spacer span three rows and assign each visible element to the appropriate row. If the hero's content grows taller than 100vh, the grid expands gracefully without breaking the layout. Browser support in modern browsers is solid.
The trade-off is that you must coordinate grid column starting lines if your page has sidebars or other columns. But compared to manually tracking multiple elements' heights across breakpoints, the grid method is far more maintainable, and arguably cleaner to reason about once you accept the invisible spacer pattern.



