CSS Scroll Snap get active Item
Asked Answered
D

2

10

I have a problem with CSS scroll snap. I want to detect the snapped element via JavaScript and assign it, e.g., a CSS class or similar.

Unfortunately, I haven't found a way to detect the snapped element yet. Background: I have a list with subitems, which are scrolled, always the middle item in the list should be highlighted:

Layout

Layout

I already tested the intersection observer with rootMargin to detect the vertically centered element, but it’s more buggy than useful.

HTML

<div class="timeline-menu-dropdown-years-list-container">
    <ul class="timeline-menu-dropdown-years-list timeline-menu-dropdown-years-text" id="yearcontainer">
        <li id="2010" class="timeline-dropdown-year" data-target="year-2010">2010</li>
        <li id="2009" class="timeline-dropdown-year" data-target="year-2009">2009</li>
        <li id="2008" class="timeline-dropdown-year" data-target="year-2008">2008</li>
        <li id="2007" class="timeline-dropdown-year" data-target="year-2007">2007</li>
        <li id="2006" class="timeline-dropdown-year" data-target="year-2006">2006</li>
        <li id="2005" class="timeline-dropdown-year" data-target="year-2005">2005</li>
        <li id="2004" class="timeline-dropdown-year" data-target="year-2004">2004</li>
        <li id="2003" class="timeline-dropdown-year" data-target="year-2003">2003</li>
        <li id="2002" class="timeline-dropdown-year" data-target="year-2002">2002</li>
        <li id="2001" class="timeline-dropdown-year" data-target="year-2001">2001</li>
        <li id="2000" class="timeline-dropdown-year" data-target="year-2000">2000</li>
    </ul>
</div>

CSS

.timeline-menu-dropdown-years-list-container {
  max-height: 250px;
  overflow: scroll;
  scroll-snap-type: y mandatory;
  -ms-overflow-style: none;  /* Internet Explorer and Edge */
  scrollbar-width: none;  /* Firefox */
  padding-top: 45%;
  padding-bottom: 40%;
  scroll-padding-top: 45%;
  scroll-padding-bottom: 40%;
}

.timeline-dropdown-year {
  color: white;
  font-size: 14px;
  border-bottom: 2px solid white;
  margin-right: 11%;
  margin-left: 34%;
  scroll-snap-align: center;
}

How can I fix it?

At the end, you should be able to scroll through this timeline. The active element should always snap to the center and be visually highlighted.

Dhyana answered 29/3, 2021 at 9:56 Comment(9)
I can not reproduce your code. Maybe you find a specific class or id of the current date in your list in Developer Tools to detect the snapped element. Cause of in your Screenshot I can see that the snapped element has different css styling. So it should have something unique.Conjoined
The code is just the base. The li elements snap in, i would like to detect the current snapped item and assign a css class to it (e. g. "active")Dhyana
I examine the dom when scrolling. As I see there is no dom change when its scrolling.Conjoined
I know I'm looking for a way to find out when an item is snapped so I can manipulate it afterwards.Dhyana
Possibly related: CSS Scroll snap not snapping on to sectionsStocktaking
The syntax highlighting of the CSS content is weird, e.g. near "scrollbar-width".Stocktaking
Similar question: CSS Scroll-Snap API? - "do the CSS scroll-snap properties have a API (events) that can be hooked into via JavaScript?"Stocktaking
The problem with "scrollbar-width" seems to have been fixed now (2022-07-02). Now it is only -ms-overflow-style that is weirdly highlighted.Stocktaking
It is likely due to a version update.Stocktaking
T
3

I have the same problem. I solved it with JavaScript here: Implementation of CSS scroll snap event stop and element position detection

[].slice.call(container.children).forEach(function (ele, index) {
    if (Math.abs(ele.getBoundingClientRect().left - container.getBoundingClientRect().left) < 10) {
        // The 'ele' element at this moment is the element currently
        // positioned. Add class .active for example!

    } else {
        // The 'ele' element at the moment is not
        // the currently positioned element
    }
});

Put this in the event scroll:

// Timer, used to detect whether horizontal scrolling is over
var timer = null;
// Scrolling event start
container.addEventListener('scroll', function () {
    clearTimeout(timer);
    // Renew timer
    timer = setTimeout(function () {
        // No scrolling event triggered. It is considered that
        // scrolling has stopped do what you want to do, such
        // as callback processing
    }, 100);
});
Tetracycline answered 8/7, 2021 at 19:48 Comment(8)
Hi I am looking at your solution and am trying to implement it, but do not understand your first codeblock. Can you check out this codepen? And is a scroll event listener ideal performance wise? Is it possible to use Intersection Observe API? I am implementing something similar, but multiple list elements will be visible. codepen.io/blacklizardd/pen/rNzadzrTrygve
plz try to understand what you do and dont just paste... you don't load "container" variable.........Tetracycline
Like where does this empty array come from? [].slice.call(container.children)Trygve
it's just a cast array to use forEach ;)Tetracycline
u can use ; let args = Array.from(arguments); or args = [...arguments]; instead...Tetracycline
This works, but it the delay is noticeable. In any case, if your item list very long, wouldn't think be less performant? Alternatively I'm looking at trying to detect initial scroll direction and using that to determine the target snap element before any movement is finished.Cathern
You should not use getBoundingClientRect() in this way. It will cause a reflow each time the scroll event is triggered. It would be preferable to store the bound values in an object beforehand. If you don't know about reflow and it's effect on performance I highly suggest looking them up.Karns
@TheSloth have you link avout this? can you add your answer with your own code plz?Tetracycline
K
2

An improvement over the current answer would be to store the bounds of each elements beforehand to prevent a reflow each time the scroll event is triggered.

let container = document.querySelector('.timeline-menu-dropdown-years-list-container')
let containerBounds = null
let currentItem = 0

// Store items as an array of objects
const items = Array.from(document.querySelectorAll('.timeline-dropdown-year')).map(el => ({el}))

const storeBounds = () =>{
    // Store the bounds of the container
    containerBounds = container.getBoundingClientRect() // triggers reflow
    // Store the bounds of each item
    items.forEach((item, i)=>{
        item.bounds = item.el.getBoundingClientRect() // triggers reflow
        item.offsetY = item.bounds.top - containerBounds.top // store item offset distance from container
    })
}
storeBounds() // Store bounds on load

const detectCurrent = () => {
    const scrollY = container.scrollTop // Container scroll position
    const goal = container.bounds.height / 2 // Where we want the current item to be, 0 = top of the container

    // Find item closest to the goal
    currentItem = items.reduce((prev, curr) => {
        return (Math.abs(curr.offsetY - scrollY - goal) < Math.abs(prev.offsetY - scrollY - goal) ? curr : prev); // return the closest to the goal
    });

    // Do stuff with currentItem
    // here

}
detectCurrent() // Detect the current item on load

window.addEventListener('scroll', () => detectCurrent()) // Detect current item on scroll
window.addEventListener('resize', () => storeBounds()) // Update bounds on resize in case they have changed

A further improvement would to have a debounce function on the scroll event so as to reduce how often the calculation is done. We do not really need to check every time the scroll event is fired and could set an interval of around 200 ms between each check.

You may also need to update the bounds when the page is resized depending if the layout changes or not.

Resources for those not familiar with how browser reflow works:

Karns answered 23/5, 2022 at 17:45 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.