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>
</>
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:
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>
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.
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
.
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.
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;
}
}