By mapping the current scroll offset to an attribute on the html
element we can style elements on the page based on the current scroll position. We can use this to build, for example, a floating navigation component.
This article was originally published on my personal blog
This is the HTML we'll work with, a nice <header>
component that we want to float on top of the content when we scroll down.
<header>I'm the page header</header>
<p>Lot's of content here...</p>
<p>More beautiful content...</p>
<p>Content...</p>
As a start, we'll listen for the 'scroll'
event on the document
and we'll request the current scrollY
position each time the user scrolls.
document.addEventListener('scroll', () => {
document.documentElement.dataset.scroll = window.scrollY;
});
We have the scroll position stored in a data attribute on the html
element. If you view the DOM using your dev tools it would look like this <html data-scroll="0">
.
Now we can use this attribute to style elements on the page.
/* Make sure the header is always at least 3em high */
header {
min-height: 3em;
width: 100%;
background-color: #fff;
}
/* Reserve the same height at the top of the page as the header min-height */
html:not([data-scroll='0']) body {
padding-top: 3em;
}
/* Switch to fixed positioning, and stick the header to the top of the page */
html:not([data-scroll='0']) header {
position: fixed;
top: 0;
z-index: 1;
/* This box-shadow will help sell the floating effect */
box-shadow: 0 0 .5em rgba(0, 0, 0, .5);
}
This is basically it, the header will now automatically detach from the page and float on top of the content when scrolling down. The JavaScript code doesn't care about this, it's task is simply putting the scroll offset in the data attribute. This is nice as there is no tight coupling between the JavaScript and the CSS.
There are still some improvements to make, mostly in the performance area.
But first, we have to fix our script for situations where the scroll position is not at the top when the page loads. In those situations, the header will render incorrectly.
When the page loads we'll have to quickly get the current scroll offset. This ensures we're always in sync with the current state of affairs.
// Reads out the scroll position and stores it in the data attribute
// so we can use it in our stylesheets
const storeScroll = () => {
document.documentElement.dataset.scroll = window.scrollY;
}
// Listen for new scroll events
document.addEventListener('scroll', storeScroll);
// Update scroll position for first time
storeScroll();
Next we're going to look at some performance improvements. If we request the scrollY
position the browser will have to calculate the positions of each and every element on the page to make sure it returns the correct position. It's best if we not force it to do this each and every scroll interaction.
To do this, we'll need a debounce method, this method will queue our request till the browser is ready to paint the next frame, at that point it has already calculate the positions of all the elements on the page so it won't do it again.
// The debounce function receives our function as a parameter
const debounce = (fn) => {
// This holds the requestAnimationFrame reference, so we can cancel it if we wish
let frame;
// The debounce function returns a new function that can receive a variable number of arguments
return (...params) => {
// If the frame variable has been defined, clear it now, and queue for next frame
if (frame) {
cancelAnimationFrame(frame);
}
// Queue our function call for the next frame
frame = requestAnimationFrame(() => {
// Call our function and pass any params we received
fn(...params);
});
}
};
// Reads out the scroll position and stores it in the data attribute
// so we can use it in our stylesheets
const storeScroll = () => {
document.documentElement.dataset.scroll = window.scrollY;
}
// Listen for new scroll events, here we debounce our `storeScroll` function
document.addEventListener('scroll', debounce(storeScroll));
// Update scroll position for first time
storeScroll();
By marking the event as passive
we can tell the browser that our scroll event is not going to be canceled by a touch interaction (for instance when interacting with a plugin like Google Maps). This allows the browser to scroll the page immediately as it now knows that the event won't be canceled.
document.addEventListener('scroll', debounce(storeScroll), { passive: true });
With the performance issues resolved we now have a stable way to feed data obtained with JavaScript to our CSS. I've set up a demo below so you can take a look at the working code.
I'm very interested in other ways we can use this technique so if you have any ideas, share them below.
Top comments (6)
You can use
position:sticky;
to save a little bit of code too.Nice, Thanks Andrew!
Just checked sticky browser support and it's actually quite great.
I also noticed the demo didn't work on Safari, it seems it still needs the
-webkit-
prefix.WHY DIDN'T I THINK OF THIS!? This is just like frontend reactive framework one way data flow, update the att, and let CSS handle applying the styles, no need to conditionally add a class at a certain breakpoint!
Haha :D I felt exactly the same when I first set this up, it's a very straightforward and flexible way of solving these kinds of problems :-)
Won’t this only work for a single scroll point vs all others? Seems pointless to continually update the attribute instead of just adding a binary class.
The idea behind it is two-fold, one, you got the data, why not share it with CSS. Two, the JavaScript logic is as dumb as it gets, no knowledge of classes or naming or behavior that is attached. By keeping the JavaScript this basic it's less prone to errors or growth due to edge cases.
For non-binary situations:
dev.to/rikschennink/using-smart-cs...
I'm not saying one or the other is better, it is simply an interesting alternative approach.