Using scroll snap events
TheCSS scroll snap module defines twoscroll snap events:scrollsnapchanging
andscrollsnapchange
. These enable running JavaScript in response to the browser determining that newscroll snap targets are pending and selected, respectively.
This guide provides an overview of these events, along with complete examples.
In this article
Events overview
Scroll snap events are set on ascrolling container that contains potential scroll snap targets:
The
scrollsnapchanging
event is fired when the browser determines that a new scroll snap target will be selected when the current scroll gesture ends. This is thepending scroll snap target. Specifically, this event fires during a scrolling gesture, each time the user moves over potential new snap targets. While thescrollsnapchanging
event may fire multiple times for each scrolling gesture, it does not fire on all potential snap targets for a scrolling gesture that moves over multiple snap targets. Rather, it fires just for the last target that the snapping will potentially rest on.The
scrollsnapchange
event is fired at the end of a scrolling operation when a new scroll snap target is selected. Specifically, this event fires when a scrolling gesture is completed, but only if a new snap target is selected. This event fires just before thescrollend
event fires.
Let's look at an example that shows the two events in action (you'll see how this is built later on in the article):
Have a go at scrolling up and down the list of boxes:
- Try slowly scrolling the container up and down without releasing the scrolling gesture. For example, drag your finger(s) over the scrolling area on a touchscreen device or trackpad, or hold down the mouse button on the scroll bar and move the mouse. The boxes you move over should turn a darker gray color as you move over them, and then return to normal as you move away from them again. This is the
scrollsnapchanging
event in action. - Now try releasing the scrolling gesture; the nearest box to your scrolling position should animate to a purple color, with white text. The animation occurs when the
scrollsnapchange
event fires. - Finally, try scrolling fast. For example, flick your finger hard on the screen, to scroll past several potential targets before starting to come to rest near a target further down the scroll container. You should only see one
scrollsnapchanging
event fire as the scrolling starts to slow, before thescrollsnapchange
event fires and the selected snap target turns purple.
TheSnapEvent
event object
Both of the above events share theSnapEvent
event object. This has two properties that are key to how scroll snap events work:
snapTargetBlock
returns a reference to the element snapped to in theblock direction when the event fired, ornull
if scroll snapping only occurs in the inline direction so no element is snapped to in the block direction.snapTargetInline
returns a reference to the element snapped to in theinline direction when the event fired, ornull
if scroll snapping only occurs in the block direction so no element is snapped to in the inline direction.
These properties enable event handler functions to report the element that has been snapped to (in the case ofscrollsnapchange
) or the element thatwould be snapped to if the scrolling gesture were to be finished now (in the case ofscrollsnapchanging
) — in one- and two-dimensions. You can then manipulate these elements in any way you want, for example by directly setting styles on them via theirstyle
properties, setting classes on them that have styles defined for them in a stylesheet, etc.
Relationship with CSSscroll-snap-type
The property values available onSnapEvent
correspond directly to the value of thescroll-snap-type
CSS property set on the scroll container:
- If the snap axis is specified as
block
(or a physical axis value that equates toblock
in the current writing mode), onlysnapTargetBlock
returns an element reference. - If the snap axis is specified as
inline
(or a physical axis value that equates toinline
in the current writing mode), onlysnapTargetInline
returns an element reference. - If the snap axis is specified as
both
,snapTargetBlock
andsnapTargetInline
return an element reference.
Handling one-dimensional scrollers
If you are dealing with a horizontal scroller, only the event object'ssnapTargetInline
property will change as the snapped element changes if the content has a horizontalwriting-mode
, or thesnapTargetBlock
property if the content has a verticalwriting-mode
.
Conversely, if you are dealing with a vertical scroller, only thesnapTargetBlock
property will change as the snapped element changes if the content has a horizontalwriting-mode
, or thesnapTargetInline
property if the content has a verticalwriting-mode
.
In both cases, the non-changing property of the two returnsnull
.
Let's look at an example snippet to show a typical one-dimensional scroll snap event handler function:
scrollingElem.addEventListener("scrollsnapchange", (event) => { event.snapTargetBlock.className = "select-section";});
In this snippet, ascrollsnapchange
handler function is set on a block-direction scrolling container element that snap targets appear inside. When the event fires, we set aselect-section
class on thesnapTargetBlock
element, which could be used to style a newly-selected snap target to look like it has been selected (for example, with an animation).
Handling two-dimensional scrollers
If you are dealing with a horizontaland vertical scroller, the code gets more complex. This is because thesnapTargetBlock
propertyand thesnapTargetInline
property values both return an element reference (neither returnsnull
), and one or the other will change value depending on which direction you scroll in and thewriting-mode
of the content:
- If the scroller is scrolled horizontally, the
snapTargetInline
property will change as the snapped element changes if the content has a horizontalwriting-mode
, or thesnapTargetBlock
property if the content has a verticalwriting-mode
. - If the scroller is scrolled vertically, the
snapTargetBlock
property will change as the snapped element changes if the content has a horizontalwriting-mode
, or thesnapTargetInline
property if the content has a verticalwriting-mode
.
To handle this, you will likely need to keep track of whether it was thesnapTargetBlock
or thesnapTargetInline
element that changed. Let's look at an example:
const prevState = { snapTargetInline: "s1", snapTargetBlock: "s1",};scrollingElem.addEventListener("scrollsnapchange", (event) => { if (!(prevState.snapTargetBlock === event.snapTargetBlock.id)) { console.log( `The container was scrolled in the block direction to element ${event.snapTargetBlock.id}`, ); } if (!(prevState.snapTargetInline === event.snapTargetInline.id)) { console.log( `The container was scrolled in the block direction to element ${event.snapTargetBlock.id}`, ); } prevState.snapTargetBlock = event.snapTargetBlock.id; prevState.snapTargetInline = event.snapTargetInline.id;});
In this snippet, we first define an object (prevState
) that stores the ID of the previoussnapTargetBlock
andsnapTargetInline
elements.
In the event handler function, we useif
statements to test whether:
- The
prevState.snapTargetBlock
ID is equal to the ID of the currentevent.snapTargetBlock
element. - The
prevState.snapTargetInline
ID is equal to the ID of the currentevent.snapTargetInline
element.
If the values are different, it means that the scroller has been scrolled in that direction (block or inline), and we log a message to console to indicate this. In a real example, you'd likely style the snapped element in some way to indicate that it has been snapped to.
We then update the values ofprevState.snapTargetBlock
andprevState.snapTargetInline
ready for when the event handler next runs.
For the remainder of this article, we'll look at a couple of complete scroll snap event examples, which you can play with in the live rendered versions at the end of each section.
One-dimensional scroller example
This example features a vertically-scrolling<main>
element containing multiple light gray<section>
elements, which are all scroll snap targets. When a new snap target is pending, it will turn a darker shade of gray. When a new snap target is selected, it will smoothly animate to purple with white text. If a different snap target was previously selected, it will smoothly animate back to gray with black text.
HTML
The HTML for the example is a single<main>
element. We will add the<section>
elements dynamically with JavaScript later on, to save on page space.
<main></main>
CSS
* { box-sizing: border-box;}html { height: 100%;}body { display: flex; align-items: center; justify-content: center; height: inherit;}h2 { font-size: 1rem; letter-spacing: 1px;}section { font-family: Arial, Helvetica, sans-serif; border-radius: 5px; background: #eeeeee; box-shadow: inset 1px 1px 4px rgb(255 255 255 / 0.5), inset -1px -1px 4px rgb(0 0 0 / 0.5); width: 150px; height: 150px; display: flex; align-items: center; justify-content: center;}
In the CSS, we start off by giving the<main>
element a chunky blackborder
and a fixedwidth
andheight
. We set itsoverflow
value toscroll
so overflowing content will be hidden and can be scrolled to, and setscroll-snap-type
toblock mandatory
so that snap targets in the block direction only will always be snapped to.
main { border: 3px solid black; width: 250px; height: 450px; overflow: scroll; scroll-snap-type: block mandatory;}
Each<section>
element is given amargin
of50px
to separate out the<section>
elements and make the scroll snapping behavior more apparent. We then setscroll-snap-align
tocenter
, to specify that we want to snap to the center of each snap target. Finally, we apply atransition
to smoothly animate to and from the style changes applied when a snap target selection has been made or is pending.
section { margin: 50px auto; scroll-snap-align: center; transition: 0.5s ease;}
The style changes mentioned above will be applied through classes applied to the<section>
elements via JavaScript. Theselect-section
class will be applied to signify a selection — this set a purple background and white text color. Thepending
class will be applied to signify a pending snap target selection — this colors the target selection's background a darker gray.
.pending { background-color: #cccccc;}.select-section { background: purple; color: white;}
JavaScript
In the JavaScript, we start by grabbing a reference to the<main>
element and defining the number of<section>
elements to generate (in this case, 21) and a variable to begin counting from. We then use awhile
loop to generate the<section>
elements, giving each one a childh2
with text that readsSection
plus the current value ofn
.
const mainElem = document.querySelector("main");const sectionCount = 21;let n = 1;while (n <= sectionCount) { mainElem.innerHTML += ` <section> <h2>Section ${n}</h2> </section> `; n++;}
Now on to thescrollsnapchanging
event handler function. When a child of the<main>
element (i.e., any<section>
element) becomes a pending snap target selection, we:
- Check to see if an element previously had the
pending
class applied and, if so, remove it. This is so that only the current pending target is given thepending
class and colored darker gray. We don't want previously-pending targets that are no longer pending to keep the styling. - Give the element referenced by the
snapTargetBlock
property (which will be one of the<section>
elements) thepending
class so it turns a darker gray.
mainElem.addEventListener("scrollsnapchanging", (event) => { const previousPending = document.querySelector(".pending"); if (previousPending) { previousPending.classList.remove("pending"); } event.snapTargetBlock.classList.add("pending");});
Note:We don't need to worry about thesnapTargetInline
event object property for this demo — we are only scrolling vertically and the demo is using a horizontal writing mode, therefore only thesnapTargetBlock
value will change. In this case,snapTargetInline
will always returnnull
.
When a scrolling gesture ends, and a<section>
element is actually selected as a snap target, thescrollsnapchange
event handler function fires. This:
- Checks to see if a snap target was previously selected — i.e., if a
select-section
class was previously applied to an element. If so, we remove it. - Applies the
select-section
class to the<section>
element referenced in thesnapTargetBlock
property so that the snap target that was just selected will have the selection animation applied to it.
mainElem.addEventListener("scrollsnapchange", (event) => { const currentlySnapped = document.querySelector(".select-section"); if (currentlySnapped) { currentlySnapped.classList.remove("select-section"); } event.snapTargetBlock.classList.add("select-section");});
Result
Try scrolling up and down the scroll container and observing the behavior described above:
Two-dimensional scroller example
This example is similar to the previous one, except that it features a horizontally-and vertically-scrolling<main>
element containing multiple light gray<section>
elements, which are all snap targets.
The HTML for the example is the same as for the previous example — a single<main>
element.
<main></main>
CSS
* { box-sizing: border-box;}html { height: 100%;}body { display: flex; align-items: center; justify-content: center; height: inherit;}section { font-family: Arial, Helvetica, sans-serif; border-radius: 5px; background: #eeeeee; box-shadow: inset 1px 1px 4px rgb(255 255 255 / 0.5), inset -1px -1px 4px rgb(0 0 0 / 0.5); width: 150px; height: 150px; display: flex; align-items: center; justify-content: center; scroll-snap-align: center;}h2 { font-size: 1rem; letter-spacing: 1px;}
The CSS for this example is similar to the CSS in the previous example. The most significant differences are as follows.
First let's look at the<main>
element styling. We want the<section>
elements to be laid out as a grid, so we useCSS grid layout to specify that we want them displayed in seven columns, using agrid-template-columns
value ofrepeat(7, 1fr)
. We also specify the space around the<section>
elements by settingpadding
andgap
on the<main>
element rather thanmargin
on the<section>
elements.
Finally, since we are scrolling in both directions in this example, we setscroll-snap-type
toboth mandatory
so that snap targets in the block directionand inline direction will always be snapped to.
main { display: grid; grid-template-columns: repeat(7, 1fr); padding: 100px; gap: 50px; overflow: scroll; border: 3px solid black; width: 350px; height: 350px; scroll-snap-type: both mandatory;}
Next, we are going to use CSS animations in this example instead of transitions. This results in more complex code, but enables more fine-grained control over the animations applied.
We first define the classes that will be applied to signal that a snap target selection has been made or is pending. Theselect-section
anddeselect-section
classes will apply keyframe animations to signify a selection or deselection. Thepending
class will be applied to signify a pending snap target selection (it applies a darker gray background to the selection, as in the previous example).
The@keyframes
animate from a gray background and black (default) text color to a purple background and white text color, and back again, respectively. The latter animation is somewhat different from the first one — it also usesopacity
to create a fade out/fade in effect.
.select-section { animation: select 0.8s ease forwards;}.deselect-section { animation: deselect 0.8s ease forwards;}.pending { background-color: #cccccc;}@keyframes select { from { background: #eeeeee; color: black; } to { background: purple; color: white; }}@keyframes deselect { 0% { background: purple; color: white; opacity: 1; } 80% { background: #eeeeee; color: black; opacity: 0.1; } 100% { background: #eeeeee; color: black; opacity: 1; }}
JavaScript
In the JavaScript, we start off in the same way as with the previous example, except that this time we generate 49<section>
elements, and we give each one an ID ofs
plus the current value ofn
to help track them later on. With the CSS grid layout we specified above, we have seven columns of seven<section>
elements.
const mainElem = document.querySelector("main");const sectionCount = 49;let n = 1;while (n <= sectionCount) { mainElem.innerHTML += ` <section> <h2>Section ${n}</h2> </section> `; n++;}
Next we specify an object calledprevState
, which allows us to keep track of the previously-selected snap target at any point — its properties store the previous inline and block snap targets' IDs. This is important for figuring out if we need to style the new block target or the new inline target each time an event handler fires.
const prevState = { snapTargetInline: "s1", snapTargetBlock: "s1",};
For example, let's say the scroll container is scrolled so that the ID of the newSnapEvent.snapTargetBlock
element has changed (it doesn't equal the ID stored inprevState.snapTargetBlock
), but the ID of the newSnapEvent.snapTargetInline
element is still the same as the ID stored inprevState.snapTargetInline
. This means that we've moved to a new snap target in the block direction, so we should styleSnapEvent.snapTargetBlock
, but we've not moved to a new snap target in the inline direction, so we shouldn't styleSnapEvent.snapTargetInline
.
This time around, we'll explain thescrollsnapchange
event handler function first. In this function, we:
- Start by making sure that a previously-selected
<section>
element snap target (as signified by the presence of theselect-section
class) has thedeselect-section
class applied so it shows the deselection animation. If no snap target was previously selected, we apply theselect-section
class to the first<section>
in the DOM so it shows up as selected when the page first loads. - Compare the previously-selected snap target ID to the newly-selected snap target ID, for both the blockand inline selections. If they are different, it indicates that the selection has changed, so we apply the
select-section
class to the appropriate snap target to visually indicate this. - Update
prevState.snapTargetBlock
andprevState.snapTargetInline
to be equal to the IDs of the scroll snap targets that were just selected, so that when the event next fires, they will be the previous selections.
mainElem.addEventListener("scrollsnapchange", (event) => { if (document.querySelector(".select-section")) { document.querySelector(".select-section").className = "deselect-section"; } else { document.querySelector("section").className = "select-section"; } if (!(prevState.snapTargetBlock === event.snapTargetBlock.id)) { event.snapTargetBlock.className = "select-section"; } if (!(prevState.snapTargetInline === event.snapTargetInline.id)) { event.snapTargetInline.className = "select-section"; } prevState.snapTargetBlock = event.snapTargetBlock.id; prevState.snapTargetInline = event.snapTargetInline.id;});
When thescrollsnapchanging
event handler function fires, we:
- Remove the
pending
class from the element that previously had it applied so that only the current pending target is given thepending
class and colored darker gray. - Give the current pending element the
pending
class so it turns a darker gray, but only if it has not already got theselect-section
class applied — we want a previously selected target to keep the purple selection styling until a new target is actually selected. We also include an extra check in theif
statements to make sure we style only the inline or block pending snap target, depending on which one has changed. Again, we compare the previous snap target to the current snap target in each case.
mainElem.addEventListener("scrollsnapchanging", (event) => { const previousPending = document.querySelector(".pending"); if (previousPending) { previousPending.className = ""; } if ( !(event.snapTargetBlock.className === "select-section") && !(prevState.snapTargetBlock === event.snapTargetBlock.id) ) { event.snapTargetBlock.className = "pending"; } if ( !(event.snapTargetInline.className === "select-section") && !(prevState.snapTargetInline === event.snapTargetInline.id) ) { event.snapTargetInline.className = "pending"; }});
Result
Try scrolling horizontally and vertically around the scroll container and observing the behavior described above:
Scroll snap events onDocument
andWindow
In this article, we've covered the scroll snap events that fire on theElement
interface, but the same events also fire on theDocument
andWindow
objects. See:
Document
scrollsnapchange
andscrollsnapchanging
event references.Window
scrollsnapchange
andscrollsnapchanging
event references.
These work in much the same way as theElement
versions, except that the overall HTML document has to be set as the scroll snap container (i.e.,scroll-snap-type
is set on the<html>
element).
For example, if we took a similar example to the ones we've looked at above, where we've got a<main>
element containing significant content:
<main> <!-- Significant content --></main>
The<main>
element could be turned into a scroll container using a combination of CSS properties, for example:
main { width: 250px; height: 450px; overflow: scroll;}
You could then implement scroll snapping behavior on the scrolling content by specifying thescroll-snap-type
property on the<html>
element:
html { scroll-snap-type: block mandatory;}
The following JavaScript snippet would cause thescrollsnapchange
event to fire on the HTML document when a child of the<main>
element becomes a newly-selected snap target. In the handler function, we set aselected
class on the child referenced by theSnapEvent.snapTargetBlock
, which could be used to style it to look like it has been selected (for example, with an animation) when the event fires.
document.addEventListener("scrollsnapchange", (event) => { event.snapTargetBlock.classList.add("selected");});
We could fire the event onWindow
instead, to achieve the same functionality:
window.addEventListener("scrollsnapchange", (event) => { event.snapTargetBlock.classList.add("selected");});
See also
scrollsnapchanging
eventscrollsnapchange
eventSnapEvent
- CSS scroll snap module
- Scroll Snap Events on developer.chrome.com (2024)