Tutorial: Performance first carousels with CSS Scroll Snap

Intro

Part of my talk at Shopify Unite 2022 was focused on a key concept of reducing Javascript in a project, and a little thing called "code splitting" in the attempt to reduce impact on Time to First Byte (TTFB) and Time to Interactive (TTI). Before you keep reading, this tutorial does require a basic understanding of CSS, HTML / Liquid and Javascript. If that is fine by you, then keep reading :).

Carousels are everywhere on websites today. Typically they appear across the most important pages on a website (especially commerce websites) either above the fold, or on product pages containing product cross-sells. So the reality is, if you get carousels right you can set the tone for other components across your project, but also have a surprisingly large impact on your recorded and actual performance too.

The goal of this tutorial is for you to be able to remove chunky, dreaded carousel module or library that is adding jQuery / massive JS load to your website - and replace with with a Javascript-less solution (or very little JS - depending on how far you want to take it).

So let's get into it!

CSS Scroll Snap

Before we jump into code, it's important to understand the code and the concept of why CSS scroll snap is so powerful. The CSS Scroll Snap specification gives us a way to snap scrolling to certain points as the user scrolls through a document. This can be helpful in creating a more app-like experience on mobile or even on the desktop for some types of applications - which is perfect for carousels.

CSS Scroll Snap is supported in all major browsers. A previous version of the specification was implemented in some browsers, and may appear in tutorials and articles. If material includes the deprecated scroll-snap-points-x and scroll-snap-points-y properties, it should be considered outdated - so dont use it!.

How does it work?

Scroll snapping is the act of adjusting the scroll offset of a scroll container to be at a preferred snap position once the scroll operation finishes. This can be controled using the scroll-snap-type on the parent container, and scroll-snap-align on the child element. To learn how these properties work, and what options are available, you can take a look here.

The Component

As this article is based off my talk at Shopify Unite 2022, we will be making a product carousel for a Shopify theme using Liquid, CSS and a tiny bit of Javascript. Don't worry if you're not a Shopify developer, the code can still be taken and applied to regular HTML / JSX quite easily.

First up, the Liquid code

The below code isn't complicated. It's a simple container with a loop of products within it.

<section id="shopify-section-{{ section.id }}" class="shopify-section">
  <div class='slider'>
    <nav class='slider__nav'>
      <button title='Next product' data-prev><span>&larr;</span></button>
      <button title='Previous product' data-next><span>&rarr;</span></button>
    </nav>
    <div class='slider__slides' data-slider>
      {%- for product in section.settings.featured_collection.products -%}
        <figure class='slider__slide' data-slide>
          {%- render 'product-card', product: product -%}
        </figure>
      {%- endfor -%}
    </div>
  </div>
</section>
{% schema %}
...
{% endschema %}

Let's look at some CSS

Now below is a full CSS file you can copy and it will work, but before you do that I want to just touch on the attributes that are making this thing work.

  • The overscroll-behavior-x CSS property on .slider__slides sets the browser's behavior when the horizontal boundary of a scrolling area is reached.
  • The scroll-snap-type CSS property on .slider__slides sets how strictly snap points are enforced on the scroll container in case there is one.
    • We're using x so the scroll container snaps to snap positions in its horizontal axis only.
    • We're also using mandatory which means the visual viewport of this scroll container will rest on a snap point if it isn't currently scrolled. That means it snaps on that point when the scroll action finishes, if possible.
  • Lastly, the The scroll-snap-align CSS property on .slider__slide specifies the box's snap position as an alignment of its snap area (as the alignment subject) within its snap container's snapport (as the alignment container).
/* Parent Container*/
.slider {
  display: flex;
  align-items: center;
  position: relative;
  width: 100%;
}

/* Slides Container*/
.slider__slides {
  position: relative;
  display: flex;
  align-items: center;
  overflow-x: scroll;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
  gap: 0;
  width: 100%;
}

/* Each slide */
.slider__slide {
  scroll-snap-align: start;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 25%; /* 4 slides visible at a given time, this can be setup to be controlled by a theme setting */
  max-height: 620px;
  height: 100%;
}

/*Hide scroll bar*/
.slider__slides::-webkit-scrollbar {
  display: none;
}

/* Navigation */
.slider__nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 10;
  pointer-events: none;
}
.slider__nav > button {
  font-family: sans-serif;
  background: white;
  border: 1px gray;
  border-radius: 100%;
  color: #4d4d4d;
  padding: 1rem;
  transition: all 0.2s ease-in-out;
  pointer-events: initial;
  height: 48px;
  width: 48px;
  position: relative;
  margin: 0px 30px;
}
.slider__nav > button > span {
  position: relative;
  top: -4px;
}
.slider__nav > button:hover {
  cursor: pointer;
  transform: scale(1.1);
}

Javascript, for some extra finesse

At this point you have a working horizontally scrolling CSS Scroll Snap carousel. Woohoo!

Now the reality is, that this probably isn't going to cut it for the everyday requirement. So what we're going to do is introduce a small amount of Javascript to make those navigation buttons (left and right arrows) work!

const slider = document.querySelector('[data-slider]')
const prevButton = document.querySelector('[data-prev]')
const nextButton = document.querySelector('[data-next]')
const slideContainer = document.querySelector('[data-slide]')
function slide(direction) {
  let left
  const { scrollLeft } = slider
  const width = slideContainer.getBoundingClientRect().width
  switch (direction) {
    case 'prev':
      left = scrollLeft - width
      break
    case 'next':
    default:
      left = scrollLeft + width
      break
  }
  slider.scroll({
    left,
    behavior: 'smooth',
  })
}
if (slider && prevButton && nextButton) {
  prevButton.addEventListener('click', () => slide('prev'))
  nextButton.addEventListener('click', () => slide('next'))
}

Summary

CSS Snap Scroll is super easy to implement on websites and can be done by any developer with relative ease. Feel free to take this code and use it on your projects and begin the process of migrating off carousel libraries. If you you'd like to see more content like this let me know! Happy coding.