This article is about the perils of CSS position: sticky
. It's a fantastic development for CSS, nevertheless, there are some pitfalls to watch out for, as well as some non-intuitive behaviors to keep in mind.
At the time of this writing, this is an issue still under discussion within the specification.
TL;DR
Elements with position: sticky
have to stick to something. By design, they always stick to their nearest scrollable ancestor. But, "scrollable ancestor" does not imply "scrolling ancestor". If your scrollable ancestor never has the opportunity to actually scroll, then you will never observe the sticky behavior.
The most common problematic case is putting position: sticky
on a child element and overflow-y: scroll
(or more commonly, just overflow: auto
) on a parent element. In this scenario, position sticky is applied correctly, but you will never observe it. The gotcha is that both elements are within a <body>
element with unbounded height. This means that the parent element will have it's maximum height, and thus will have no need to scroll. So, the parent is scrollable, but will never actually scroll. Meanwhile the <body>
is actually scrolling, but nothing is sticky relative to the body element's overflow, so no sticky behavior is observed.
Background
Before we get to the core problem, we need to all get on the same page regarding some background facts that will inform the problem. To mention them briefly, 1, The body has an implicit overflow: auto
, 2, position: sticky
will stick to the nearest scrolling ancestor, and 3, for simple pages, the nearest scrolling ancestor is the <body>
. Feel free to skip any section here, if you feel you already understand it well enough.
1. The body has an implicit overflow: auto
When you visit an <html>
page, it's height is unbounded by default. You vertically navigate the page by scrolling through it. It's like there's an implicit overflow: auto
on the <body>
of an <html>
page. You won't find that CSS property on the 'user agent stylesheet', but it's essentially there.
(Quick side note, overflow: auto
and overflow: scroll
are essentially the same thing, and I will only refer to overflow: auto
in what follows. The small difference between them is that, with overflow: scroll
, the scroll bar will be visible even if there's no overflow to scroll through, while with overflow: auto
the user agent only displays the scrollbar when necessary. This means there's a possibility of horizontal stutter if ever the scrollbar bar gets dynamically introduced on the page. More information can be found on the perennially magnificent MDN docs.)
2. position: sticky
will stick to the nearest scrolling ancestor
First, for the semi-official, but slightly confusing quote from the MDN docs:
Note that a sticky element "sticks" to its nearest ancestor that has a "scrolling mechanism" (created when overflow is hidden, scroll, auto, or overlay), even if that ancestor isn't the nearest actually scrolling ancestor. This effectively inhibits any "sticky" behavior.
Second, for an over-the-top explication of the reasoning behind this fact, let's listen in on a conversation between Socrates and Turing:
Turing: Let's make a new CSS position, position: sticky
.
Socrates: But what... is sticky?
Turing: Well, if an element would scroll off a page, instead it stays put, like it swapped from position: relative
to position: fixed
all of a sudden.
Socrates: And what if nothing scrolls?
Turing: Then the sticky behavior would be unobserved, but the element would still be capable of sticking, which is what counts.
Socrates: But, if nothing scrolls, then nothing sticks.
Turing: True. But we can't know how the developers will construct the page. All we can do is guarantee that an element with position: sticky
is sticky-capable.
Socrates: Yes, I agree. Now, for another question.
Turing: I'm not surprised. Go ahead.
Socrates: What if everything scrolls?
Turing: Well, an element can't be sticky in relation to more than one other element. Let me prove this. For example, if we assumed an element could stick to more than one other element, say, two elements, then we could construct a scenario where both elements were off the page, but were instructing the sticky element to stick in two separate locations on the page.
Socrates: That does seem absurd.
Turing: So, then, an element can only "stick" to one other element. That element should be an ancestor, and, for the stickiness to be observed, that ancestor will need to be scrollable. After all, if it's not scrollable, then the stickiness will never be observed, and you might as well assign the element a different position property.
Socrates: True.
Turing: In conclusion, an element with position: sticky
will need to be sticky in relation to a scrollable ancestor.
Socrates: I agree.
Turing: As a naive first take, we can have the user declare the element it will be sticky in relation to. For example, position: sticky, #element-id
.
Socrates: This will not suffice, Turing. The position
property does not historically declare what the element is positioned in relation to. Also, we do not want to force the user of the property to identify the element it is positioned in relation to, since that element could theoretically change over time on a dynamic web page. It will have to be implicit. Furthermore, that implicit assigning will have to be logical, so the user can deduce the association.
Turing: Good point, Socrates. Hmm... Okay, I've got it. An element with the property of position: sticky
will indicate to the CSS engine that it should begin inspecting the element's ancestors, starting at the nearest ancestor. It will find the first "scrollable ancestor". That is, an ancestor with the overflow
property set to something that indicates scrollability. At that point, it will stop it's search, having found the element's nearest scrollable ancestor. The element will then "stick" in relation to that ancestor. This is a good solution because we can guarantee that every element has at least one "overflowable", i.e., "scrollable" ancestor, which is the <body>
itself. So this search for a scrollable ancestor is guaranteed to terminate.
Socrates: I like this solution. It is perhaps unfortunate that users will need to do a thorough-going analysis of their DOM to locate the nearest scrollable ancestor. That being said, it is at least consistent.
Turing: Well said, Socrates. It is an unfortunate fact, but let's not have that inhibit us from implementing this useful additional position
. Let's proceed with the implementation.
Socrates: I agree.
Wow. That was great dialogue. What an opportunity we just had. Let's all be grateful and move on now.
3. For simple pages, the nearest scrolling ancestor is the <body>
As Turing alluded to earlier, position: sticky
will "stick" to the nearest scrolling ancestor. In a simple setup, that nearest ancestor is the <body>
, which, as was mentioned previously, has an implicit overflow: auto
.
I bet that most first time users of position: sticky
will assume that an element is always "stuck" to the <body>
element. In the least, I thought it did, at first. The trickiness of stickiness is that often, that assumption is correct! But, that assumption is not always true.
The problem: Unobserved stick
Let's say there are three elements, Body, Parent, and Child. Body is the <body>
element, Parent is an arbitrary element somewhere inside Body, and Child is an arbitrary element somewhere inside Parent. You set position: sticky
on Child. You set overflow: auto
on Parent, thereby making Parent scrollable. Child, because of position: sticky
, begins looking for it's nearest scrollable ancestor. It finds Parent and stops. But, Parent is inside the Body element, which, as we've discussed previously, has an implicit overflow: auto
itself. This means that Body will make itself scroll in order to fit all of the content within it, including all of Parent's content. Another way of saying it is that Body's height is unbounded. Thus Parent will never have the opportunity to actually scroll. From Parent's perspective, it's content fits fully within Body. If Parent could scroll, you'd see Child being "sticky" in relation to it, but Parent can't scroll, and, unless something inhibits the unbounded height of Parent, it never will scroll. All the while, Body is scrolling all over the place, but that doesn't matter to Child. Child doesn't stick to Body, Child sticks to Parent.
The only way you can get Child to stick to Body is to turn off the overflow: auto
property on Parent, as well as every other overflowing element between Child and Body. If you can't do that, you're out of luck. You don't need to convince me that you have a good reason for having overflow
set on some arbitrary component in between Child and Body. I believe you. But still, you're out of luck.
The most depressing variant of the problem
The most heinous version of this problem is when you want scrolling on a cross-axis. Sadly, overflow-x: scroll
with overflow-y: visible
still counts as overflow. So if, for example, you want horizontal scroll on a wide table and you want that same table to have sticky table headers on vertical scroll... well, you can't!
There's an open issue in the W3C CSS Working Group draft around this specification issue. Check out this comment, which references this fantastic JSFiddle which best demonstrates this saddest version of the problem.
Temporary solutions
There are solutions floating around that require manipulating the DOM in ways that achieve effective equivalents of the desired behavior. However, they do not solve either problem mentioned above. My recommendation is to explain this problem to your team and see if you can design your way around the problem. That's what my team did.
Conclusion
I recommend using position: sticky
. It simplifies things immensely. But the troll under the bridge is that, sometimes, it is anything but simple.