Targeting position:sticky elements that are currently in a 'stuck' state
CssCss SelectorsCss PositionCss Problem Overview
position: sticky works on some mobile browsers now, so you can make a menu bar scroll with the page but then stick to the top of the viewport whenever the user scrolls past it.
But what if you want to restyle your sticky menu bar slightly whenever it's currently 'sticking'? eg, you might want the bar to have rounded corners whenever it's scrolling with the page, but then as soon as it sticks to the top of the viewport, you want to get rid of the top rounded corners, and add a little drop shadow underneath it.
Is there any kind of pseudoselector (eg ::stuck
) to target elements that have position: sticky
and are currently sticking? Or do browser vendors have anything like this in the pipeline? If not, where would I request it?
NB. javascript solutions are not good for this because on mobile you usually only get a single scroll
event when the user releases their finger, so JS can't know the exact moment that the scroll threshold was passed.
Css Solutions
Solution 1 - Css
There is currently no selector that is being proposed for elements that are currently 'stuck'. The Postioned Layout module where position: sticky
is defined does not mention any such selector either.
Feature requests for CSS can be posted to the www-style mailing list. I believe a :stuck
pseudo-class makes more sense than a ::stuck
pseudo-element, since you're looking to target the elements themselves while they are in that state. In fact, a :stuck
pseudo-class was discussed some time ago; the main complication, it was found, is one that plagues just about any proposed selector that attempts to match based on a rendered or computed style: circular dependencies.
In the case of a :stuck
pseudo-class, the simplest case of circularity would occur with the following CSS:
:stuck { position: static; /* Or anything other than sticky/fixed */ }
:not(:stuck) { position: sticky; /* Or fixed */ }
And there could be many more edge cases that would be difficult to address.
While it's generally agreed upon that having selectors that match based on certain layout states would be nice, unfortunately major limitations exist that make these non-trivial to implement. I wouldn't hold my breath for a pure CSS solution to this problem anytime soon.
Solution 2 - Css
In some cases a simple IntersectionObserver
can do the trick, if the situation allows for sticking to a pixel or two outside its root container, rather than properly flush against. That way when it sits just beyond the edge, the observer fires and we're off and running.
const observer = new IntersectionObserver(
([e]) => e.target.toggleAttribute('stuck', e.intersectionRatio < 1),
{threshold: [1]}
);
observer.observe(document.querySelector('nav'));
Stick the element just out of its container with top: -2px
, and then target via the stuck
attribute...
nav {
background: magenta;
height: 80px;
position: sticky;
top: -2px;
}
nav[stuck] {
box-shadow: 0 0 16px black;
}
Example here: https://codepen.io/anon/pen/vqyQEK
Solution 3 - Css
I wanted a pure CSS solution that would allow styling a 'stuck' element, as though a ::stuck
pseudo-selector exists (alas, still not in 2021).
I have created a pure CSS hack that achieves the effect with no JS and fits my needs. It works by having two copies of the element, one is sticky
and the other isn't (unstuck
one), and this latter one covers up the sticky
element until you scroll by it.
Demo: https://codepen.io/TomAnthony/pen/qBqgErK
Alternative demo: https://codepen.io/TomAnthony/pen/mdOvJYw (this version is more what I wanted, I wanted the sticky items to only appear once they were 'stuck' - it also means no duplicate content.)
HTML:
<div class="sticky">
<div class="unstuck">
<div>
Box header. Italic when 'stuck'.
</div>
</div>
<div class="stuck">
<div>
Box header. Italic when 'stuck'.
</div>
</div>
</div>
CSS:
.sticky {
height: 20px;
display: inline;
background-color: pink;
}
.stuck {
position: -webkit-sticky;
position: sticky;
top: 0;
height: 20px;
font-style: italic;
}
.unstuck {
height: 0;
overflow-y: visible;
position: relative;
z-index: 1;
}
.unstuck > div {
position: absolute;
width: 100%;
height: 20px;
background-color: inherit;
}
Solution 4 - Css
Someone on the Google Developers blog claims to have found a performative JavaScript-based solution with an IntersectionObserver.
Relevant code bit here:
/**
* Sets up an intersection observer to notify when elements with the class
* `.sticky_sentinel--top` become visible/invisible at the top of the container.
* @param {!Element} container
*/
function observeHeaders(container) {
const observer = new IntersectionObserver((records, observer) => {
for (const record of records) {
const targetInfo = record.boundingClientRect;
const stickyTarget = record.target.parentElement.querySelector('.sticky');
const rootBoundsInfo = record.rootBounds;
// Started sticking.
if (targetInfo.bottom < rootBoundsInfo.top) {
fireEvent(true, stickyTarget);
}
// Stopped sticking.
if (targetInfo.bottom >= rootBoundsInfo.top &&
targetInfo.bottom < rootBoundsInfo.bottom) {
fireEvent(false, stickyTarget);
}
}
}, {threshold: [0], root: container});
// Add the top sentinels to each section and attach an observer.
const sentinels = addSentinels(container, 'sticky_sentinel--top');
sentinels.forEach(el => observer.observe(el));
}
I haven't replicated it myself, but maybe it helps someone stumbling over this question.
Solution 5 - Css
Not really a fan of using js hacks for styling stuff (ie getBoudingClientRect, scroll listening, resize listening), but this is how I'm currently solving the problem. This solution will have issues with pages that have minimizable/maximizable content (<details>), or nested scrolling, or really any curve balls whatsoever. That being said, it's a simple solution for when the problem is simple as well.
let lowestKnownOffset: number = -1;
window.addEventListener("resize", () => lowestKnownOffset = -1);
const $Title = document.getElementById("Title");
let requestedFrame: number;
window.addEventListener("scroll", (event) => {
if (requestedFrame) { return; }
requestedFrame = requestAnimationFrame(() => {
// if it's sticky to top, the offset will bottom out at its natural page offset
if (lowestKnownOffset === -1) { lowestKnownOffset = $Title.offsetTop; }
lowestKnownOffset = Math.min(lowestKnownOffset, $Title.offsetTop);
// this condition assumes that $Title is the only sticky element and it sticks at top: 0px
// if there are multiple elements, this can be updated to choose whichever one it furthest down on the page as the sticky one
if (window.scrollY >= lowestKnownOffset) {
$Title.classList.add("--stuck");
} else {
$Title.classList.remove("--stuck");
}
requestedFrame = undefined;
});
})
Solution 6 - Css
A compact way for when you have an element above the position:sticky
element. It sets the attribute stuck
which you can match in CSS with header[stuck]
:
HTML:
<img id="logo" ...>
<div>
<header style="position: sticky">
...
</header>
...
</div>
JS:
if (typeof IntersectionObserver !== 'function') {
// sorry, IE https://caniuse.com/#feat=intersectionobserver
return
}
new IntersectionObserver(
function (entries, observer) {
for (var _i = 0; _i < entries.length; _i++) {
var stickyHeader = entries[_i].target.nextSibling
stickyHeader.toggleAttribute('stuck', !entries[_i].isIntersecting)
}
},
{}
).observe(document.getElementById('logo'))