javascriptreactjsscroll-snap

Stop user from scrolling more than 1 element at once


The problem

Basically I have a mobile website that has some videos. Each video takes 100% width and 100% height. I'm trying to make a scroll snap type feed, where the user can scroll only 1 video at a time and if they reach a certain point on the page while scrolling, it will snap to the next video. It's kinda like tiktok's video feed or instagram reels as an idea.

I'm using the scroll-snap package which kinda gets my half way to what I'm trying to achieve. It snaps when scrolling slowly, however if I was on mobile, I can just scroll really fast and allow the scroll momentum to skip videos. I'm trying to make it so the user is only able to scroll 1 video at a time no matter how hard they scroll.

Here is what's happening: https://streamable.com/f98slq. As you can see, I'm able to scroll past more than 1 video at a time.

What I've tried

I tried to get the scroll velocity when scrolling the feed. If it's higher than a certain value, I would apply a style that stops the page scroll for 10ms. This didn't really work though as the scroll momentum was able to scroll the page even if the scroll velocity wasn't high. I don't really like this solution though.

I'm not really sure the best way to approach this problem. Any feedback would be appreciated.

This is my code:

    // Scroll snap
    useEffect(() => {
        const { unbind } = createScrollSnap(MobileSnapContainer.current!, {
            snapDestinationX: '0%',
            snapDestinationY: '100%',
            timeout: 0,
            duration: 100,
            threshold: 0.1,
            snapStop: true,
        }, () => {})

        return () => unbind()
    }, [])

    return (
    <>        
        <InfiniteScroll
                dataLength={mobileVideos.length}
                next={loadMore}
                hasMore={hasMore}
                loader={null}
                scrollableTarget="container"
            >
                <div 
                    className="overflow-auto select-none hide-scrollbar" 
                    id="container" 
                    ref={MobileSnapContainer}
                >
                    {mobileVideos.map((data: video, index: number) => (
                        <SingleMobileVideo 
                          index={index}
                        />
                    ))}  
                </div>
            </InfiniteScroll>
      </>

Solution

  • A strategy would be to check the position of certain elements passes through a range that triggers a scroll freeze.

    So on scroll, if the scrolled pixels amount is more or less close to the position of one of those elements, you can freeze it for a certain time.

    Four things here, before we go to my demo:

    1. How to freeze the scroll?
    2. Why a range?
    3. When to unfreeze it?
    4. Some considerations...

    How to freeze the scroll?

    Quite simple... You can set the scrollTop to a fixed value at each scroll event.

    let freezed = true;
    let noScrollElement = document.querySelector(".outer");
    noScrollElement.scrollTop = 2920;
    
    noScrollElement.addEventListener("scroll", function(e) {
      if (freezed) {
        e.target.scrollTop = 2920;
      }
    })
    
    document.querySelector("button").addEventListener("click", function(e) {
      freezed = false;
    })
    .outer {
      height: 300px;
      overflow: scroll;
    }
    
    .space {
      height: 3000px;
    }
    <div class="outer">
      <div class="space"></div>
      <div>
        No! You just can't scroll me! <button>Unfreeze</button>
      </div>
      <div class="space"></div>
    </div>

    Why a range?

    Because the scrollTop value will not match exactly the position of the elements. So you have to evaluate a certain amount of pixels around it. It estimated +/- 16px to be quite good even with normal quick spins of the mouse wheel. That makes a range of 32px. Up to you to adjust it. There is about nothing to do with abnormals quick spins.

    When to unfreeze it?

    You have to! .o(lol) It can be after a certain delay (like I've done in my demo) or after the video played a certain time. You would have to check the video currentTime.

    Some considerations...

    While it may be fun to code it... Your users most probably will dislike like hell. So do not abuse on the freezed time. It should be subtile.

    The most interesting use case for it would be the about the super fast scroll... Like doing a fast swipe up gesture on a mobile which scrolls a 10km page in a second. But sadly, this solution won't be reliable for that case.

    Now the relevant parts of my demo

    Which can be ran on replit

    // Some globals
    let snapPrecision = 16;  // pixels
    let unsnapTime = 5000;  // milliseconds
    let freezed = false;    // boolean to know if the scrolling is freezed
    let freezedAt = 0;      // Pixels where it is frozen
    let lastSnaped = 0;     // index of the sanppable element
    
    // Have all position of the divs with className snap in state
    const [snapPos, snapPosSetter] = React.useState([])
    
    // If the page updates, like new elements appear,
    // Update the positions array in the state
    React.useEffect(() => {
      // Get the position of the snap elements
      let snapPos = [];
      Array.from(document.querySelectorAll(".snap")).forEach(s => snapPos.push(s.getBoundingClientRect().top));
    
      // Update the state
      snapPosSetter(snapPos)
    }, [])
    
    // The scroll handler
    function scrollHandler(e){
      // console.log(e.target.scrollTop)
    
      // Scrolled position
      let scrolled = parseInt(e.target.scrollTop);
    
      // If not freezed, check if there is a match between
      // the scrolled position and all the snap items in state
      if (!freezed) {
    
        // If the scrolled position is withing +/- snapPrecision
        // and not the last snapped
        for (let i = 0; i < snapPos.length; i++) {
          if (
            scrolled + snapPrecision > snapPos[i] &&
            scrolled - snapPrecision < snapPos[i] &&
            lastSnaped != i
          ) {
            freezed = true;
            freezedAt = snapPos[i];
            lastSnaped = i;
    
            // Unsnap it in unsnapTime (milliseconds)
            setTimeout(function () {
              console.log("unfreezed");
              freezed = false;
            }, unsnapTime);
    
            break;
          }
        }
      }
    
      // This is the part that really freezes the scroll
      if (freezed) {
        console.clear();
        console.log("FREEZED at ", freezedAt);
    
        // Force the scroll at a specific position!
        e.target.scrollTop = freezedAt;
      }
    }