Scroll snapping is an interaction enhancement (when implemented correctly) and is now a native CSS feature that helps users snap to certain scroll points. This benefit is that it helps users snap to various elements of content that are supposed to be observed together, reduces the amount of scrolling required overall, and prevents over-scrolling.
In this tutorial, you’ll learn how to build HTML containers with content that can be snapped to. We’ll use CSS’s scroll snapping properties and take a look at some other CSS scroll snapping features coming to a web browser near you. The objective is to avoid using JavaScript, which is no longer necessary for scroll snapping, to build a more performant solution. We’ll use only CSS.
In addition, you may have correctly surmised that there are a number of UX-related caveats to scroll snapping, which we’ll discuss along the way.
Let’s begin!
Jump ahead:
First of all, here’s what we’ll be making:
See the Pen
CSS Scroll Snapping by Daniel Schwarz (@mrdanielschwarz)
on CodePen.
To clarify — that’s five snap points with content.
Here’s the full code:
<!doctype html> <html> <head> <style> * { margin: 0; line-height: 1.5; box-sizing: border-box; font-family: sans-serif; } main { height: 100vh; overflow-y: scroll; scroll-snap-type: y mandatory; } html, body { overflow: hidden; } section { height: 100vh; display: flex; background: #eee; scroll-snap-align: start; scroll-snap-stop: always; } section:nth-child(odd) { background: #ddd; } div { margin: auto; text-align: center; } </style> </head> <body> <main> <section> <div> <h1>Section 1</h1> <p>Lorem ipsum howdy</p> </div> </section> <section id="section-2"> <div> <h2>Section 2</h2> <p>Lorem ipsum howdy</p> </div> </section> <section> <div> <h2>Section 3</h2> <p>Lorem ipsum howdy</p> </div> </section> <section> <div> <h2>Section 4</h2> <p>Lorem ipsum howdy</p> </div> </section> <section> <div> <h2>Section 5</h2> <p>Lorem ipsum howdy</p> </div> </section> </main> </body> </html>
Please note that the child combinator (>
) is used to illustrate the hierarchy of the HTML elements — let it be understood that this isn’t the optimal way to write CSS selectors at all.
Before we begin, let me explain what each CSS selector is for.
The universal selector resets a few things. For example, margins. It doesn’t have anything to do with CSS scroll snapping, so we can just ignore these rules:
* { }
This is the scroll snapping container, which is just as important as the snap points themselves (in terms of CSS):
main { }
These are the five snap points:
main > section { }
This rule ensures that every other snap point section is a different color so we can easily observe where each snap point section starts and ends. It exists only for this tutorial:
main > section:nth-child(odd) { background: #ddd; }
This rule targets the content of each snap point section. Specifically, it ensures that the content is centered inside the section. It’s also only for the purposes of this tutorial:
main > section > div { margin: auto; text-align: center; }
OK, now let’s dive into the CSS scroll snapping specifics.
First, you must specify a height
for the scroll snap container (or a width
for a horizontal container). Luckily, this doesn’t need to be a fixed height, so if you don’t know how long the container will be (it might even be responsive), you can use viewport units instead. I’ve used 100vh
, which makes the container as large as the viewport is (vertically fluid).
It’s an odd requirement for sure, but a requirement nonetheless:
main { height: 100vh; max-height: 100vh; /* this will also work */ }
The container oddly requires overflow-y: scroll
too:
main { height: 100vh; overflow-y: scroll; }
If you end up with double scrollbars, then you might also need to throw in the following:
html, body { overflow: hidden; }
Finally, the CSS property scroll-snap-type
somewhat activates scroll snapping by specifying how it behaves. It comes in two parts, separated by a space.
The first part can be x
, y
, or both
— which specifies the axes on which we want scroll snapping to function on. In this example, we’re using y
— for example, vertical scroll snapping.
The second part can be mandatory
or proximity
, which determines what happens when users release their scroll. With mandatory
, the scroll will always snap to the nearest snap point. With proximity
, the scroll will only snap if it’s close to a snap target, doing nothing otherwise.
Use mandatory
if you’re dead-set on users snapping to specific regions only and proximity
if you consider snapping nice to have. It’s worth noting that both options negatively impact the user’s experience (at first, at least) as they’re unlikely to expect their scroll to snap.
Scroll snapping works best when users do bold flicky scrolls, which results in a nice effect but only when users know that the scroll will behave this way beforehand. For this reason, scroll snapping is best suited to websites committed to making scroll snapping a familiar UX pattern or whose users return regularly.
In short, it’s a useful effect, but if you’re only using it to spice up a landing page, it might be best to avoid it. Scroll snapping should be treated as a UX pattern:
main { height: 100vh; overflow-y: scroll; scroll-snap-type: y mandatory; }
It’s worth noting that we can use the root (<html>
) as our scroll snap container in scenarios where the entire webpage is just one huge scroll snapping experience. In this scenario, we don’t need to set height
or overflow-y
since <html>
has them already. However, this doesn’t work on the <body>
for reasons unknown.
Declaring height: 100vh;
on the snap target ensures that they fully employ all of the available vertical space. Think of each snap target as, for instance, a single selling point for a hypothetic product that must be viewed in its entirety for maximum effect (like a slideshow).
However, two potential problems that come with having large snap targets. Firstly, as mentioned before, if users don’t perform bold flicky scrolls, they risk not snapping to the next snap target and, in some cases, falling back to the previous one (which, as you can imagine, would be rather frustrating). Remember: scroll snapping just isn’t worth it if you’re not reusing the pattern and capitalizing on users becoming familiar with it.
The second problem is that if the snap target uses the full viewport and the content is larger than the viewport on mobile devices, scroll snapping will prevent the content from being accessible, so before committing to using scroll snapping, make sure that it compliments the content:
main > section { height: 100vh; }
scroll-snap-align
accepts various keyword values (start
, end
, center
) that determine which side of the snap target the web browser should snap to. For example, declaring scroll-snap-align: start;
on horizontal snap targets would ensure that web browsers snap to their left side. For vertical snap targets, the top side.
scroll-snap-align
doesn’t appear to have a default value because not providing one causes scroll snapping to fail, so while you’d expect start
to be the default value anyway, we must still declare it:
main > section { height: 100vh; scroll-snap-align: start; }
scroll-snap-stop: always;
ensures that users can’t accidentally skip over snap targets. There isn’t a right or wrong time to use it, it just comes down to preference, so it might be worth A/B testing it with your users. Just note that it doesn’t work with scroll-snap-type: <axis> proximity
:
main > section { height: 100vh; scroll-snap-align: start; scroll-snap-stop: always; }
Web browser support for CSS scroll snapping is high. That being said, it’s still being developed and what’s on the horizon right now is the ability to style snap targets and/or even kickstart animations within them using only CSS. More specifically, the upcoming :snapped
CSS pseudo selector.
Once browsers support the :snapped
pseudo selector (and similar selectors, :snapped-x
and :snapped-y
), you’ll be able to use them like this:
section:snapped { /* CSS declarations */ }
It’s so minimal and straightforward! Naturally, the ability to style snap targets is more useful for snap targets that aren’t full-screen. Take a look at the demos from the Chrome DevRel team below that simulate how :snapped
is likely to work in these scenarios: horizontal, vertical, and both axes.
While we’re waiting for web browsers to adopt :snapped
, there’s an alternative JavaScript solution that involves the Intersection Observer API, which essentially observes the viewport, waiting for certain elements to come into it (in this case, the elements are the snap targets). When a snap target comes into view, we can add a class that adds styles. Take a look at the examples below.
In this first example, the Observer
targets a specific snap point. It waits for it to come into view and then adds a class to it — this is ideal for doing something unique to a specific snap point, such as triggering a unique CSS transition/animation:
let callback = (entries, observer) => { entries.forEach(entry => { if (!entry.isIntersecting) { /* remove class when snap target becomes invisible */ entry.target.classList.remove("transition"); } else { /* add class when snap target becomes visible */ entry.target.classList.add("transition"); } }); }; /* only 50% of the element needs to be visible */ let options = { threshold: 0.5 } let observer = new IntersectionObserver(callback, options); /* do this to #section-2 specifically */ let section2 = document.querySelector("#section-2") observer.observe(section2);
In this second example, forEach
is used to loop through all of the snap targets, adding a separate Observer
to each one but one that has the same effect on all snap targets:
let callback = (entries, obs) => { entries.forEach(entry => { if (!entry.isIntersecting) { entry.target.classList.remove("styles"); } else { entry.target.classList.add("styles"); } }); }; let options = { threshold: 0.5 } let observer = new IntersectionObserver(callback, options); /* target all section elements */ document.querySelectorAll("section").forEach(section => observer.observe(section));
Ultimately, the Intersection Observer API accomplishes the same thing as :snapped
will do eventually, but it requires JavaScript and more code overall. It also causes a negligible decrease in web performance.
However, the upside to the Intersection Observer API is that we can use it to do much more than adding and removing CSS (we can use it to play and restart videos, for example). Also, the threshold
option determines how much the snap target needs to be in the viewport before the callback
is called, which provides us more granular control over when the class gets toggled.
It’s also worth keeping an eye on snapChanging()
and snapChanged()
, two JavaScript events that should become supported by web browsers eventually. Although I can’t see what they would bring to the table that the Intersection Observer API doesn’t already.
Here’s how they would be used, for reference:
document.querySelector("#section-2").addEventListener("snapchanging", event => { /* do something */ }); document.querySelector("#section-2").addEventListener("snapchanged", event => { /* do something */ });
What do you think? Useful, or not? If you have any thoughts, drop a comment in the comment section below.
Scroll snapping can be fun when used appropriately. Although, there aren’t many scenarios where it enhances the UX. In the right circumstances, scroll snapping can feel rather intuitive to users once they grow familiar with its usage on a website as a UX pattern.
Hopefully, you feel more comfortable entertaining scroll snapping now that it’s super easy to implement using only CSS. Meanwhile, it’s important to be cautious of using it in scenarios where it doesn’t appear to serve as more than a vanity effect.
I recommend keeping an eye on the :snapped
CSS pseudo selector because once web browsers support it, it’ll reduce the amount of JavaScript needed.
And, as always, thanks for reading!
As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app, mobile app, or website. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.
Modernize how you debug web and mobile apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.