Content-aware Infinite Scroll Loop using JavaScript


This project came out from a key highlight of the Luna for CTFd theme, a CTFd theme trying to reproduce the atmosphere of the game Project SEKAI: Colorful Stage feat. Hatsune Miku. In order to recreate the unique and symbolic music selection interface, I went forward to write this piece of code out myself.

Below is a simple demo of the final outcome.

Before I started coding, the first thing I did was to look for existing implementations of scroll loops. There are mainly two types of implementation, one is to have the scroll content overflow the container, and programmatically manipulate the scroll position, the other is to append elements dynamically to the end of the scroll content when user scrolling near to the end.

I went with the former strategy because the latter would only allow scrolling in a single direction. However, the former strategy relies on the fact that the list stretching beyond the view size, and has no consideration on the selected item within the list.

Here is a list of features I implemented for the scroll loop list, and I will talk about them one by one in detail afterwards.

  • Repeat the list items at least once both above and below the original list to cover the entire screen. This is to ensure that we have enough content for the loop transition to happen smoothly.
  • Scroll in both directions should jump forward or backwards accordingly to ensure a smooth loop.
    • When an item is selected, once it is being scrolled out of the screen, it should appear again in the opposite side following the scroll direction once appeared.
  • When an item is selected, it should be highlighted, and there should be a smooth transition to move it to the center of the screen.
  • The list should able to be updated when filtered with different criteria, and the selected item will be cleared when the criteria changes.

Repeating the list items

Repeating the list items is seemingly the easiest problem to solve here. The only factor we need to determine the number of times we need to repeat each side. Usually this factor is determined by the size of the viewport, and must be updated when the window resizes. However, in this example, we are going to use the screen size as the basis for performance reasons. In this way, we do not need to keep track of the size of window constantly.

Here we assume that the screen size is constant, that means the script cannot handle cases where the window is being dragged to another screen with a larger size, or is rotated 90°. Yet these the size will be recalculated when the user switches to another category, so it is not too much of an issue.

The number of times we need to repeat the list can be found with an easy division:

\left\lceil{\text{height of screen}\over\text{height of an item}\times\text{number of items}}\right\rceil

Hereafter, we will refer to the list at the center as the “center segment” of the list.

Scroll jumps to the opposite direction for a smooth loop

This is the most basic requirement to achieve a scroll loop. When the user scrolls to the top or the bottom end of the list, we let the browser jump back to the opposite end to allow the user to continue scrolling. This is done by listening to the scroll event of the scrolling container and update the scroll offset when necessary.

Below is an animation illustrating the logic.

As illustrated, despite there is a sudden jump in the scroll position, visually the user would feel like the list is kept on scrolling because the content in the viewport remains the same before and after the jump.

The remaining question would be when we jump and how far we jump. I will talk about these case by case.

Design decision: Item selection
The original game has taken an approach to always select the centered item while scrolling. While this approach may eliminate the need of differentiate the jump logic, in my use case, each selection of an item would send an HTTP request to the server. Selecting items while scrolling would unnecessarily increase the load of the server, and the strategy is thus abandoned.

Scenario 1: No items are selected

When no items are selected, all of the repeating segments will be fully identical to the center one. In this case, we will use the center of scrolling container as a basis. When the center segment is scrolled pass the basis, we jump the scroll position for a full height of the center segment to ensure that the basis line always overlap with the center segment.

The length of the jump must always be a multiple of the center segment height to ensure that we do not create any visual disruption while manipulating the scroll position.

Scenario 2: An item is selected

Once an item is selected, the center segment will have an item highlighted comparing to the repeating segments. With the highlighted row, users would tend to use it as a visual probe when scrolling through the list. This means we can no longer jump through the list bluntly, as for shorter lists the selected item will be jumping around in the middle of the screen, which is less ideal visually.

Instead, we want the select item loop across the screen like the Snake, while still keeping it always within the scroll window whenever possible.

The logic I had for this scenario can be summarized in the pseudo code below:

if the selected item is out of view:
  if the same item in the previous or next repeating segment is in the view:
    // Jump to the furthest selected item in the view.
    jump max(⌈viewHeight ÷ centerHeight⌉, 1) × centerHeight
    // The view is in between two selected item
    Follow the logic in Scenario 1

The reason of not highlighting the item in every repeating segment is because that the view will be visually too busy when multiple segments are shown in the view. We only want to keep the selected item in the center segment.

Below is an animation illustrating an example of this logic, where ★ signifies the selected item.

Smooth transition to the center when an item is selected

With modern browser, smooth scroll to a specified offset is no longer hard, the scrollTo API offers an option behavior: "smooth". Specifying the offset, and the browser will do the transition animation for you. The only one trick we would need do is to ensure that the item clicked is from the center segment for highlighting.

The logic is rather simple, first check if the item clicked is within the center segment. If not, jump the scroll offset by the distance between the clicked item and the corresponding item in the center segment. Finally do a scroll transition.

This transition gives users a positive feedback that the web app has responded to their selection.

Updating the list with filters

Updating the list is in fact mostly outside of the scope of the scroll loop project, expect that the number of repeat times need to be recalculated when the length of filtered items changes.

To achieve this, you can choose whichever framework you like, or even vanilla JavaScript. In the example above, I used Alpine.js which is the front end framework used by the default CTFd theme.

There is one thing you may need to take note when using Alpine.js: clearing selection must be done after populating the filtered list. Otherwise the repeated list might not be updated correctly due to a rendering disruption.

Browser Compatibility

While this solution works for most modern browsers, there is a specific problem with Firefox (Gecko) for Windows. As outlined in their Wiki article about scroll-linked effects, scrolling events in Firefox for Windows are sent asynchronously, and usually comes with a delay. That means the user scroll will sometimes override the value set by JavaScript, causing the screen to jitter as a result.

While the article has proposed various JavaScript API for use cases that need to manipulate the scroll position itself, none of them is implemented at the moment. With this, I had to resort to a suboptimal solution, debounce the scroll position update event calls until the user stops scrolling. While this may make the scroll manipulation seem slow, it is still better than constantly jittering user’s screen during scrolling.

For reference, Firefox for Android, macOS and Linux works just fine as Chrome and Safari do.

While some of the solutions may sound hacky and not perfect across all forms, this is the best I can come up with at the moment. I definitely look forward to more advanced scroll manipulation API being implemented for a simpler and better solution that this.

Feel free to leave a comment if you have any questions or suggestions. Hope you enjoyed this!


Leave a Reply

Your email address will not be published. Required fields are marked *