Scrolling Rails and Button Controls

Posted on December 23, 2024
Takes about 6 minutes to read

Once again, here I am, hackin' away on horizontal scroll ideas. This iteration starts with a custom HTML tag. All the necessities for scroll overflow, scroll snapping, and row layout are handled with CSS. Then, as a little progressive enhancement treat, button elements are connected that scroll the previous or next set of items into view when clicked.

Behold! The holy grail of scrolling rails... the scrolly-rail!

Open CodePen demo

I'm being quite facetious about the "holy grail" part, if that's not clear. 😅 This is an initial try on an idea I'll likely experiment more with. I've shared some thoughts on potential future improvements at the end of the post. With that out of the way, let's explore!

The HTML

Wrap any collection of items with the custom tag:

<scrolly-rail>
  <ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <!-- and so on-->
  </ul>
</scrolly-rail>

While it is possible to have items without a wrapper element, if the custom element script runs and button controls are connected, sentinel elements are inserted at the start and end bounds of the scroll container. Wrapping the items makes controlling spacing between them much easier, avoiding any undesired gaps appearing due to these sentinels. We'll discover what the sentinels are for later in the post.

The CSS

Here are the main styles for the component:

scrolly-rail {
  display: flex;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;

  @media (prefers-reduced-motion: no-preference) {
    scroll-behavior: smooth;
  }
}

Finally, scroll-snap-align: start should be set on the elements that will snap into place. This snap position aligns an item to the beginning of the scroll snap container. In the above HTML, this would apply to the <li> elements.

scrolly-rail li {
  scroll-snap-align: start;
}

As mentioned earlier, this is everything our component needs for layout, inline scrolling, and scroll snapping. Note that the CodePen demo takes it a step further with some additional padding and margin styles (check out the demo CSS panel). If we'd like to wire up previous/next controls, we'll need to include the custom element script in our HTML.

The custom element script

Add the script file on the page.

<script type="module" src="scrolly-rail.js"></script>

To connect the previous/next button elements, give each an id value and add these values to the data-control-* attributes on the custom tag.

<scrolly-rail
  data-control-previous="btn-previous"
  data-control-next="btn-next"
>
  <!-- ... -->
</scrolly-rail>

<button id="btn-previous" class="btn-scrolly-rail">Previous</button>
<button id="btn-next" class="btn-scrolly-rail">Next</button>

Now clicking these buttons will pull the previous or next set of items into view. The amount of items to scroll by is based on how many are fully visible in the scroll container. For example, if we see three visible items, clicking the "next" button will scroll the subsequent three items into view.

Observing inline scroll bounds

Let's review the demo's top component. As we begin to scroll to the right, the "previous" button appears. Scrolling to the component's end causes the "next" button to disappear. Similarly we can see the bottom component's buttons fade when their respective scroll bound is reached.

Recall the sentinels discussed earlier in this post? With a little help from the Intersection Observer API, the component watches for either sentinel intersecting the visible scroll area, indicating that we've reached a boundary. When this happens, a data-bound attribute is toggled on the corresponding button element. This presents an opportunity to alter styles and provide additional visual feedback.

.btn-scrolly-rail {
  /** default styles */
}

.btn-scrolly-rail[data-bound] {
  /* styles to apply to button at boundary */
}

Future improvements

I'd love to hear from the community most specifically on improving the accessibility story here. Here are some general notes:

And if any folks have other scroll component solutions to share, please reach out or open an issue on the repo.

Back to all blog posts