Sticky Headers And Full-Height Elements: A Tricky Combination
Philip Braunen 2024-09-05T09:00:00+00:00
2024-09-10T17:01:28+00:00
I was recently asked by a student to help with a seemingly simple problem. She’d been working on a website for a coffee shop that sports a sticky header, and she wanted the hero section right underneath that header to span the rest of the available vertical space in the viewport.
Here’s a visual demo of the desired effect for clarity.
Looks like it should be easy enough, right? I was sure (read: overconfident) that the problem would only take a couple of minutes to solve, only to find it was a much deeper well than I’d assumed.
Before we dive in, let’s take a quick look at the initial markup and CSS to see what we’re working with:
<body>
<header class="header">Header Content</header>
<section class="hero">Hero Content</section>
<main class="main">Main Content</main>
</body>
.header {
position: sticky;
top: 0; /* Offset, otherwise it won't stick! */
}
/* etc. */
With those declarations, the .header
will stick to the top of the page. And yet the .hero
element below it remains intrinsically sized. This is what we want to change.
The Low-Hanging Fruit
The first impulse you might have, as I did, is to enclose the header and hero in some sort of parent container and give that container 100vh
to make it span the viewport. After that, we could use Flexbox to distribute the children and make the hero grow to fill the remaining space.
<body>
<div class="container">
<header class="header">Header Content</header>
<section class="hero">Hero Content</section>
</div>
<main class="main">Main Content</main>
</body>
.container {
height: 100vh;
display: flex;
flex-direction: column;
}
.hero {
flex-grow: 1;
}
/* etc. */
This looks correct at first glance, but watch what happens when scrolling past the hero.
The sticky header gets trapped in its parent container! But.. why?
If you’re anything like me, this behavior is unintuitive, at least initially. You may have heard that sticky
is a combination of relative
and fixed
positioning, meaning it participates in the normal flow of the document but only until it hits the edges of its scrolling container, at which point it becomes fixed
. While viewing sticky
as a combination of other values can be a useful mnemonic, it fails to capture one important difference between sticky
and fixed
elements:
A position: fixed
element doesn’t care about the parent it’s nested in or any of its ancestors. It will break out of the normal flow of the document and place itself directly offset from the viewport, as though glued in place a certain distance from the edge of the screen.
Conversely, a position: sticky
element will be pushed along with the edges of the viewport (or next closest scrolling container), but it will never escape the boundaries of its direct parent. Well, at least if you don’t count visually transform
-ing it. So a better way to think about it might be, to steal from Chris Coyier, that “position: sticky
is, in a sense, a locally scoped position: fixed
.” This is an intentional design decision, one that allows for section-specific sticky headers like the ones made famous by alphabetical lists in mobile interfaces.
Okay, so this approach is a no-go for our predicament. We need to find a solution that doesn’t involve a container around the header.
Fixed, But Not Solved
Maybe we can make our lives a bit simpler. Instead of a container, what if we gave the .header
element a fixed height of, say, 150px
? Then, all we have to do is define the .hero
element’s height as height: calc(100vh - 150px)
.
This approach kinda works, but the downsides are more insidious than our last attempt because they may not be immediately apparent. You probably noticed that the header is too tall, and we’d wanna do some math to decide on a better height.
Thinking ahead a bit,
- What if the
.header
’s children need to wrap or rearrange themselves at different screen sizes or grow to maintain legibility on mobile? - What if JavaScript is manipulating the contents?
All of these things could subtly change the .header
’s ideal size, and chasing the right height values for each scenario has the potential to spiral into a maintenance nightmare of unmanageable breakpoints and magic numbers — especially if we consider this needs to be done not only for the .header
but also the .hero
element that depends on it.
I would argue that this workaround also just feels wrong. Fixed heights break one of the main affordances of CSS layout — the way elements automatically grow and shrink to adapt to their contents — and not relying on this usually makes our lives harder, not simpler.
So, we’re left with…
A Novel Approach
Now that we’ve figured out the constraints we’re working with, another way to phrase the problem is that we want the .header
and .hero
to collectively span 100vh
without sizing the elements explicitly or wrapping them in a container. Ideally, we’d find something that already is 100vh
and align them to that. This is where it dawned on me that display: grid
may provide just what we need!
Let’s try this: We declare display: grid
on the body
element and add another element before the .header
that we’ll call .above-the-fold-spacer
. This new element gets a height of 100vh
and spans the grid’s entire width. Next, we’ll tell our spacer that it should take up two grid rows and we’ll anchor it to the top of the page.
This element must be entirely empty because we don’t ever want it to be visible or to register to screen readers. We’re merely using it as a crutch to tell the grid how to behave.
<body>
<!-- This spacer provides the height we want -->
<div class="above-the-fold-spacer"></div>
<!-- These two elements will place themselves on top of the spacer -->
<header class="header">Header Content</header>
<section class="hero">Hero Content</section>
<!-- The rest of the page stays unaffected -->
<main class="main">Main Content</main>
</body>
body {
display: grid;
}
.above-the-fold-spacer {
height: 100vh;
/* Span from the first to the last grid column line */
/* (Negative numbers count from the end of the grid) */
grid-column: 1 / -1;
/* Start at the first grid row line, and take up 2 rows */
grid-row: 1 / span 2;
}
/* etc. */
This is the magic ingredient.
By adding the spacer, we’ve created two grid rows that together take up exactly 100vh
. Now, all that’s left to do, in essence, is to tell the .header
and .hero
elements to align themselves to those existing rows. We do have to tell them to start at the same grid column line as the .above-the-fold-spacer
element so that they won’t try to sit next to it. But with that done… ta-da!
The reason this works is that a grid container can have multiple children occupying the same cell overlaid on top of each other. In a situation like that, the tallest child element defines the grid row’s overall height — or, in this case, the combined height of the two rows (100vh
).
To control how exactly the two visible elements divvy up the available space between themselves, we can use the grid-template-rows
property. I made it so that the first row uses min-content
rather than 1fr
. This is necessary so that the .header
doesn’t take up the same amount of space as the .hero
but instead only takes what it needs and lets the hero have the rest.
Here’s our full solution:
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;
}
And voila: A sticky header of arbitrary size above a hero that grows to fill the remaining visible space!
Caveats and Final Thoughts
It’s worth noting that the HTML order of the elements matters here. If we define .above-the-fold-spacer
after our .hero
section, it will overlay and block access to the elements underneath. We can work around this by declaring either order: -1
, z-index: -1
, or visibility: hidden
.
Keep in mind that this is a simple example. If you were to add a sidebar to the left of your page, for example, you’d need to adjust at which column the elements start. Still, in the majority of cases, using a CSS Grid approach is likely to be less troublesome than the Sisyphean task of manually managing and coordinating the height values of multiple elements.
Another upside of this approach is that it’s adaptable. If you decide you want a group of three elements to take up the screen’s height rather than two, then you’d make the invisible spacer span three rows and assign the visible elements to the appropriate one. Even if the hero element’s content causes its height to exceed 100vh
, the grid adapts without breaking anything. It’s even well-supported in all modern browsers.
The more I think about this technique, the more I’m persuaded that it’s actually quite clean. Then again, you know how lawyers can talk themselves into their own arguments? If you can think of an even simpler solution I’ve overlooked, feel free to reach out and let me know!
(gg, yk)