ben szabo

Image carousel using scroll-snap

It's almost possible to build a full fledged image carousel without JS. Almost.

carousel

This week Google replaced one of their Core Web Vitals, wreaking havoc on website rankings around the globe.

Like many other web teams, us too have been busy refactoring and optimizing our site to satisfy the search behemoth and most importantly, to improve our clients' experience.

Swiping left

While reviewing our website for further improvements, I came across our image viewer component as the next candidate. We use Swiper, primarily to implement a basic carousel functionality, allowing users to navigate between different images or content blocks using navigation buttons.

For that purpose, it is just simply too big, so we decided to replace it.

Pure CSS?

My colleague, Dom, had mentioned he saw some stuff done with scroll snap so I went into researching this. Found some pens where they implemented a carousel with anchor tags and text fragments (ie: #segment).

I quickly thrown a demo together in our codebase, using Next JS and Tailwind CSS. For a whole of 10 minutes I thought I had nailed it. I had kb navigation by making use of tab index. I had back and forth using text fragments.

Until I left the page - came back, clicked again and a wild bug appeared.

I soon identified a slight issue with overflow-x: scroll being incompatible with scroll-margin-top resulting in our images scrolling into view, right. below our sticky header. (The issue is probably a bit more nuanced, but for the purposes of goal it's not important)

Sliding into view

I decided to take advantage of scrollIntoView and it's options to bring an image, hidden out of sight, into the "window" of the carousel without it jumping to the top of the screen.

Soo yes, I had to take defeat and do the second best thing available and reduce the JS needed for this task to the minimum.

React Server Component

Oh yes, the sweetest thing about the solution relates to my new word of the week: interleaving, which refers to the process of mixing and nesting server and client components.

It has the chance of breaking some developer brains 🤯 as RSCs get more popular, but what this means is you can mix and nest server and client components following a few simple rules. (In short; it's all about where you import what)

Hermione meme: It's not hydration. It's "Client side server side rendering"

I've created a component with use client that takes in an array of children. Add refs, buttons, a display, some onClick logic then you have the base.

In a page, simply map over an array of images, wrapped by the carousel component then you have some pretty simple interactive carousel.

But how does this relate to interleaving, you may ask? Well, my friend, those bunch of images we've looped over are rendered on the server and the wrapper is done on the client.

Here's the code, in case you want to give it a go.

'use client';
import { ReactNode, useRef, useState } from 'react';
 
/**
* Light Slider - uses scroll snap under the hood.
* Items are passed as "children" and server rendered.
*
* @param children HTML Elements to be displayed in the slider
* @returns Slider component
*/
export default function ServerSlider({ children }: { children: ReactNode[] }) {
const slideRefs = useRef<HTMLElement[]>([]);
const [currentSlideId, setCurrentSlideId] = useState(0);
const slideCount = children.length;
 
return (
  <>
    <button
      tabIndex={-1}
      onClick={() => {
        setCurrentSlideId(currentSlideId - 1);
        slideRefs.current[currentSlideId - 1].scrollIntoView({
          behavior: 'smooth',
          block: 'nearest',
          inline: 'center',
        });
      }}
      className={`${currentSlideId === 0 ? 'hidden' : ''} `}>
      ⬅️
    </button>
    <div className="relative w-full flex gap-6 snap-x snap-mandatory snap-always overflow-hidden">
      {children.map((image, imageIdx) => {
        const idx = imageIdx + 1;
        return (
          <div
            key={`slide${idx}`}
            ref={element => {
              if (!element) return;
              slideRefs.current[imageIdx] = element;
            }}
            className="relative snap-center shrink-0 w-full aspect-[3/2] rounded-2xl overflow-clip">
            {image}
          </div>
        );
      })}
    </div>
    <button
      tabIndex={-1}
      onClick={() => {
        setCurrentSlideId(currentSlideId + 1);
        slideRefs.current[currentSlideId + 1].scrollIntoView({
          behavior: 'smooth',
          block: 'nearest',
          inline: 'center',
        });
      }}
      className={`${currentSlideId === slideCount - 1 ? 'hidden' : ''}`}>
      ➡️
    </button>
    <div className="absolute bottom-4 right-4 z-10 flex items-center justify-center rounded-lg bg-white px-3 py-2">
      <div className="leading-none">
        📷 {currentSlideId + 1} / {slideCount}
      </div>
    </div>
  </>
);
}
 


NB: I feel the double mapping might be a bit hacky so if anyone has a better way for this, whilst preserving server rendering, I'm keen to learn about it. Hit me up on X (@b3nk3).

Resources

---

Photo by Scott Webb on Unsplash