The Weird Parts of position: sticky;
The Weird Parts of position: sticky; ź“ė Ø
Using position: sticky; is one of those CSS features thatās incredibly useful, seemingly simple, and also, frequently frustrating.
The premise is simple: you want to be able to scroll your pageās content, but you want something to āstickā at the top (or anywhere). Frequently, this will be some sort of header content that you want to always stay at the top, even as the user scrolls, but it could be any sort of content (and stick edges other than the top, and at any offset).
Weāll cover a brief introduction to sticky positioning. Weāll see how it works, and then weāll look at some common, frustrating ways it can fail. Then weāll learn exactly how to fix it.
For all the code examples Iāll be using Tailwind, and later, a little React/JSX for looping. I know the Tailwind piece might be controversial to some. But for this post itāll allow me to show everything in one place, without ever requiring you, dear reader, to toggle between HTML and CSS.
Making Content Stick
Letās look at the simplest possible example of sticky positioning.
<div class="h-[500px] gap-2 overflow-auto">
<div class="flex flex-col gap-2 bg-gray-400 h-[300px]">
<span>Top</span>
<span class="mt-auto">Bottom</span>
</div>
<div class="sticky top-0 h-[100px] bg-red-300 mt-2 grid place-items-center">
<span>I'm sticky!</span>
</div>
<div class="flex flex-col bg-gray-400 h-[700px] mt-2">
<span>Top</span>
<span class="mt-auto">Bottom</span>
</div>
</div>
Our middle container has sticky top-0 which sets position: sticky and sets the top value to 0. That means we want it to āstickā at the zero position of whatever scroll container is doing the scrolling.
When Things Go Wrong
This may seem like a simple feature, but in practice it frequently goes wrong, and figuring out why can be maddening. Googling āposition sticky doesnāt workā will produce a ton of results, the vast majority of which telling you to make sure you donāt have any containers between your sticky element and your scroll container with overflow: hidden; set. This is true: if you do that, sticky positioning wonāt work.
But there are many other things which can go wrong. The next most common remedy youāre likely to see is advising that flex children be set to align-self: flex-start, rather than the default of stretch. This is great advice, and relates strongly to what weāll be covering here. But in so doing weāre going to dig deep into why this is necessary; weāll even peak briefly at the CSS spec, and when weāre done, youāll be well equipped to intelligently and efficiently debug position sticky.
Letās get started. Weāll look at two different ways you can (inadvertantly) break sticky positioning, and how to fix it.
Problem 1: Your Sticky Element is Bigger Than The Scroll Container
The header above says it all.
The sticky element you want to āstickā cannot be larger than the scrolling container in which itās attempting to stick.
Letās see an example:
<div class="h-[500px] gap-2 overflow-auto">
<div class="flex flex-col gap-2 bg-gray-400 h-[400px]">
<span>Top</span>
<span class="mt-auto">Bottom</span>
</div>
<div class="sticky top-0 h-[600px] bg-red-300 flex flex-col gap-2 flex-1 mt-2">
<span>Top</span>
<span class="mt-auto">Bottom</span>
</div>
<div class="flex flex-col gap-2 bg-gray-400 h-[400px] mt-2">
<span>Top</span>
<span class="mt-auto">Bottom</span>
</div>
</div>
Here the scroll container is 500px, and the sticky element is 600px.
This is what the code above renders.
It starts well enough, and the top does in fact stick (chriscoyier). But eventually, as you scroll far enough, the browser will ensure that the rest of the sticky element displays in its entirety, which will require the top portion of the element, which had previously āstuckā to the top, to scroll away.
This may seem like a silly example. You probably do want all of your content to show. But this problem can show up in subtle, unexpected ways. Maybe your sticky element is a little too long, but your actual content is in a nested element, correctly constrained. If that happens, everything will look perfect, but inexplicably your sticky element will overshoot at the end of the scrolling. If you see that happening, this might be why!
Problem 2: Your Sticky Element Has a Bounding Context Thatās Too Small
Letās take a look at what the CSS spec has to say (in part) on sticky positioning.
(w3.org)
For each side of the box [sticky element], if the corresponding inset property
is not auto, and the corresponding border edge of the box would be outside the
corresponding edge of the sticky view rectangle, then the box must be visually shifted (as for relative positioning) to be inward of that sticky view rectangle edge, insofar as it can while its position box remains contained within its containing block.
Emphasis mine, and that emphasized part refers to the element āsticking.ā As the sticky element begins to āviolateā the sticky constraints you set (i.e. top: 0;), then the browser forcibly shifts it to respect what you set, and āstickā it in place. But notice the very next line makes clear that this only happens while it can be contained within the containing block.
This is the crucial aspect that the entire rest of this post will obsess over. It manifests itself in many ways (frequently being able to be fixed with āstartā alignment rather than āstretchā defaults).
Letās dive in.
Hereās a sticky demo very similar to what we saw before, except I put the sticky element inside of another element (with a red outline). This immediately breaks the stickyness.
<div class="h-[500px] gap-2 overflow-auto p-1">
<div class="flex flex-col gap-2 bg-gray-400 h-[400px]">
<span>Top</span>
<span class="mt-auto">Bottom</span>
</div>
<div class="outline-5 h-[200px] outline-red-500">
<div class="sticky top-0 h-[200px] bg-red-300 flex flex-col gap-2 flex-1 mt-2">
<span>Top</span>
<span class="mt-auto">Bottom</span>
</div>
</div>
<div class="flex flex-col gap-2 bg-gray-400 h-[600px] mt-2">
<span>Top</span>
<span class="mt-auto">Bottom</span>
</div>
</div>
The sticky element is about to stick, but, if the browser were to allow it to do so, it would have to ābreak out ofā its parent. Its parent is not sticky, and so it will keep scrolling. But the browser will not let this ābreaking outā happen, so the sticking fails.
Letās make our parent (with the red outline) a little bigger, so this effect will be even clearer.
<div class="h-[500px] gap-2 overflow-auto p-1">
<div class="flex flex-col gap-2 bg-gray-400 h-[400px]">
<span>Top</span>
<span class="mt-auto">Bottom</span>
</div>
<div class="outline-5 h-[300px] outline-red-500">
<div class="sticky top-0 h-[200px] bg-red-300 flex flex-col gap-2 flex-1 mt-2">
<span>Top</span>
<span class="mt-auto">Bottom</span>
</div>
</div>
<div class="flex flex-col gap-2 bg-gray-400 h-[600px] mt-2">
<span>Top</span>
<span class="mt-auto">Bottom</span>
</div>
</div>
Now the sticky element does stick, at first. It sticks because thereās some excess space in its parent. The parent does scroll up, and as soon as the bottom of the parent becomes flush, the sticky element stops sticking. Again, this happens because the browser will not allow a sticky element to stick if doing so would break it out of an ancestor elementās bounds.
This too might seem silly; just donāt do that, you might be thinking. Letās see a more realistic example of this very phenomenon.
Flex (or Grid) Children
Letās pretend to build a top-level navigation layout for a web app. Donāt focus on the contrived pieces.
We have a main container, which weāve sized to 500px (in real life it would probably be 100dvh), and then a child, which itself is a grid container with two columns: a navigation pane on the left, and then the main content section to the right. And for reasons that will become clear in a moment, I put a purple outline around the grid child.
We want the main navigation pane frozen in place, while the main content scrolls. To (try to) achieve this, Iāve set the side navigation to be sticky with top: 0. Naturally, for this layout, you could achieve it more simply in a way that would work. But a more production ready layout for a real application would be much more complex, and would be much more likely to run into the issue weāre about to see. This entire post is about actual production issues Iāve had to debug and fix, and the learnings therefrom.
export const FlexInFlexStickyDemoVersion1 = () => {
return (
<div className="flex border-2 rounded-md">
<div className="h-[500px] flex flex-1 gap-2 overflow-auto p-1">
<div className="grid grid-rows-1 outline-2 outline-purple-600 grid-cols-[250px_1fr] flex-1">
{/* Side Navigation Pane */}
<div className="sticky top-0 flex flex-col gap-8">
{Array.from({ length: 5 }).map((_, idx) => (
<span>Side Navigation {idx + 1}</span>
))}
</div>
{/* Main Content Pane */}
<div className="flex flex-1 gap-2">
<div className="flex flex-col flex-1 gap-2">
{Array.from({ length: 100 }).map((_, idx) => (
<div className="flex gap-2">
<span>Main Content line {idx}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
And when we run this, the sticky positioning does not work at all. Everything scrolls.
The reason is that our grid child is sized to the container, which means our content cannot stick without ābreaking outā of its container (the purple grid), and as we saw, the CSS spec does not allow for this.
Why is this happening? Flex children have, by default, their align-self property set to stretch. That means they stretch in the cross axis and fill up their container. The gridās parent is a flex container in the row direction.
<div className="h-[500px] flex flex-1 gap-2 overflow-auto p-1">
That means the cross direction is vertical. So the grid grows vertically to the 500px height, and calls it a day. And this is why our stickiness is broken.
Once we understand the root cause, the fix is simple:
export const FlexInFlexStickyDemoVersion1 = () => {
return (
<div className="flex border-2 rounded-md">
<div className="h-[500px] flex flex-1 gap-2 overflow-auto p-1">
<div className="self-start grid grid-rows-1 outline-2 outline-purple-600 grid-cols-[250px_1fr] flex-1">
{/* Side Navigation Pane */}
<div className="self-start sticky top-0 flex flex-col gap-8">
{Array.from({ length: 5 }).map((_, idx) => (
<span>Side Navigation {idx + 1}</span>
))}
</div>
{/* Main Content Pane */}
<div className="flex flex-1 gap-2">
<div className="flex flex-col flex-1 gap-2">
{Array.from({ length: 100 }).map((_, idx) => (
<div className="flex gap-2">
<span>Main Content line {idx}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
Weāve added self-start alignment to both the grid container, and also the sticky element. Adding self-start to the grid tells the grid to start at the start of its flex container, and then, rather than stretch to fill its parent, to just flow as big as it needs to. This allows the grid to grow arbitrarily, so the left pane can sticky without needing to break out of its parent (which, as weāve seen, is not allowed.)
Why did we add self-start to the sticky element? Remember, grid and flex children both have stretch as the default value for align-self. When we told the grid to grow as large as it needs, then leaving the sticky element as itās default of stretch would cause it to stretch and also grow huge. That violates our original rule #1 above. Remember when we had a sticky element that was 100px larger than its scrolling container? It stuck only until the last 100px of scrolling. Leaving the sticky element as stretch would cause it to grow exactly as large as the content thatās scrolling, which would prevent it from sticking at all.
What if the side nav gets too big?
Letās make one more tweak, and stick a green outline on our sticky element.
export const FlexInFlexStickyDemoVersion1 = () => {
return (
<div className="flex border-2 rounded-md">
<div className="h-[500px] flex flex-1 gap-2 overflow-auto p-1">
<div className="self-start grid grid-rows-1 outline-2 outline-purple-600 grid-cols-[250px_1fr] flex-1">
{/* Side Navigation Pane */}
<div className="self-start outline-2 outline-green-600 sticky top-0 flex flex-col gap-8">
{Array.from({ length: 5 }).map((_, idx) => (
<span>Side Navigation {idx + 1}</span>
))}
</div>
{/* Main Content Pane */}
<div className="flex flex-1 gap-2">
<div className="flex flex-col flex-1 gap-2">
{Array.from({ length: 100 }).map((_, idx) => (
<div className="flex gap-2">
<span>Main Content line {idx}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
The self-start alignment on the sticky element keeps its content no bigger than needed. This prevents it from stretching to the (new) grid size that is arbitrarily big. But what happens if our sticky content just naturally gets too big to fit within the scroll container?
It sticks, but as the scroll container gets to the very bottom, the browser un-sticks it, so the rest of its content can scroll and be revealed.
This isnāt actually the worst thing in the world. We probably want to give users some way to see the overflowed side navigation content; but we probably want to just cap the height to the main content, and then make that element scrollable.
export const FlexInFlexStickyDemoVersion1 = () => {
return (
<div className="flex border-2 rounded-md">
<div className="h-[500px] flex flex-1 gap-2 overflow-auto p-1">
<div className="self-start grid grid-rows-1 outline-2 outline-purple-600 grid-cols-[250px_1fr] flex-1">
{/* Side Navigation Pane */}
<div className="max-h-[492px] overflow-auto self-start outline-2 outline-green-600 sticky top-0 flex flex-col gap-8">
{Array.from({ length: 20 }).map((_, idx) => (
<span>Side Navigation {idx + 1}</span>
))}
</div>
{/* Main Content Pane */}
<div className="flex flex-1 gap-2">
<div className="flex flex-col flex-1 gap-2">
{Array.from({ length: 100 }).map((_, idx) => (
<div className="flex gap-2">
<span>Main Content line {idx}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
The weird value of 492 is to allow for the 4px top and bottom padding around it (the p-1 class). In real life youād of course do something more sensible, like define some CSS variables. But for our purposes this shows what weāre interested in. The side pane is now capped at the containers height, and scrolls if needed.
Parting Thoughts
I hope this post has taught you some new things about position sticky which come in handy someday.