How to implement a slider element using React, Tailwind.css and Intersection Observer API
Tags: javascript, react, tailwindcss, webdev
Published: 2024-05-08 07:45:14

Let’s see how we could implement an image slider (a.k.a. carousel, a.k.a. slideshow) using React, Tailwind.css and as much browser-native API as possible if your designer has provided you with the following design:

<figure>
  <img src="https://miro.medium.com/v2/resize:fit:1000/format:webp/1*6XAscuHMbhghiNhXUgTsig.jpeg" alt="The design" />
  <figcaption>The design</figcaption>
</figure>

## Project setup

From now on I’ll assume that you project is already set up. If that’s not the case, I would recommend following [Next.js Getting Started Guide](https://nextjs.org/docs) and [Next.js Styling With Tailwind](https://nextjs.org/docs/app/building-your-application/styling/tailwind-css), these two should give you the necessary foundation.

## Creating the component

First of all, let’s create our component, it will be receiving a list of image URLs as an argument and render each of them in a loop:

```jsx
// src/components/image-slider.jsx

export default function ImageSlider({ images }) {
  return (
    <div>
      {images.map((url) => {
        return (
          <div id={url}>
            <img src={url} />
          </div>
        );
      })}
    </div>
  );
}
```

We also want to be able to see it, so let’s render the `ImageSlider` component at the main page and let’s use [Lorem Picsum](https://picsum.photos/) to create a list of placeholder image URLs:

```jsx
// src/app/page.js

import ImageSlider from "../components/image-slider";

export default function Page() {
  return (
    <>
      <h1>{"Image slider"}</h1>
      <ImageSlider
        images={[
          "https://picsum.photos/id/10/960/540",
          "https://picsum.photos/id/11/960/540",
          "https://picsum.photos/id/12/960/540",
        ]}
      />
    </>
  );
}
```

The result so far should look like this:

<figure>
  <img src="https://miro.medium.com/v2/resize:fit:1000/format:webp/1*qGqc2gpZtFIPWNdGstJxGw.jpeg" alt="Slides stacked on top of each other" />
  <figcaption>Slides stacked on top of each other</figcaption>
</figure>

## Horizontal slider look

Let’s make it look a bit more like a slider using [Tailwind’s flexbox utilities](https://tailwindcss.com/docs/flex). We need to add `flex flex-row overflow-x-scroll` classes to the slider and `w-full flex-shrink-0` classes to each slide:

```jsx
// src/components/image-slider.jsx

export default function ImageSlider({ images }) {
  return (
    <div className="w-full flex flex-row overflow-x-scroll">
      {images.map((url) => {
        return (
          <div key={url} className="w-full flex-shrink-0">
            <img src={url} />
          </div>
        );
      })}
    </div>
  );
}
```

We’ll also add a container to the page, so that the slider does not take the whole page, which is probably not something that you want anyway:

```jsx
// src/app/page.js

import ImageSlider from "../components/image-slider";

export default function Page() {
  return (
    <div className="w-full max-w-xl mx-auto">
      {/* ... */}
    </div>
  );
}
```

Now you should see something like this:

<figure>
  <img src="https://miro.medium.com/v2/resize:fit:1000/format:webp/1*bXN9j7ek2eNHpYmePmLzHA.jpeg" alt="Slides look good but don’t snap" />
  <figcaption>Slides look good but don’t snap</figcaption>
</figure>


## Snapping

Cool, this is definitely a slider, but there’s a catch: it won’t snap to pictures when you scroll:

<figure>
  <img src="https://miro.medium.com/v2/resize:fit:1024/format:webp/1*JNRIk9dLKSlTzkaKQnoA9A.gif" alt="Slides look good but don’t snap" />
  <figcaption>Slides look good but don’t snap</figcaption>
</figure>

Let’s fix that by adding `snap-x snap-mandatory` classes to the slider component and `snap-start` class to each slide:

```jsx
// src/components/image-slider.jsx

export default function ImageSlider({ images }) {
  return (
    <div className="w-full flex flex-row overflow-x-scroll snap-x snap-mandatory">
      {images.map((url) => {
        return (
          <div key={url} className="w-full flex-shrink-0 snap-start">
            <img src={url} />
          </div>
        );
      })}
    </div>
  );
}
```

Now it should feel much better:

<figure>
  <img src="https://miro.medium.com/v2/resize:fit:1024/format:webp/1*hGULfiSnM_HHb94GolZkCg.gif" alt="Slides do snap" />
  <figcaption>Slides do snap</figcaption>
</figure>

## Custom scroll indicator

Now we could call it a day here, but our design also requires a custom scrollbar, or rather a custom scroll position indicator, remember that little thing?

<figure>
  <img src="https://miro.medium.com/v2/resize:fit:1000/format:webp/1*BJM0pUV0StbCx7Mn3gCr4Q.jpeg" alt="The little thing (scroll indicator)" />
  <figcaption>The little thing (scroll indicator)</figcaption>
</figure>

Let’s add a wrapper element for the slider, so that we could also add the indicator, and the indicator itself (the indicator width in percent is going to be **100** divided by the **number of images**):

```jsx
// src/components/image-slider.jsx

export default function ImageSlider({ images }) {
  const indicatorWidthPercent = images.length > 0 ? 100 / images.length : 100;

  return (
    <div className="w-full">
      {/* ... */}
      <div className="w-full h-0.5 relative bg-gray-300">
        <div
          className="h-0.5 absolute top-0 left-0 bg-gray-500"
          style={{ width: `${indicatorWidthPercent}%` }}
        />
      </div>
    </div>
  );
}
```

Now it should look like this:

<figure>
  <img src="https://miro.medium.com/v2/resize:fit:1000/format:webp/1*9LElFB9_0gVAaQAwywkJ4Q.jpeg" alt="Double scrollbar" />
  <figcaption>Double scrollbar</figcaption>
</figure>

## Hiding the default scrollbar

Awesome, but that looks kinda ugly, why would we need two indicators? Let’s hide the default one by adding a padding to the slider and clipping the default scrollbar area:

```jsx
// src/components/image-slider.jsx

export default function ImageSlider({ images }) {
  const indicatorWidthPercent = images.length > 0 ? 100 / images.length : 100;

  return (
    <div className="w-full">
      <div
        className="w-full flex flex-row overflow-x-scroll snap-x snap-mandatory"
        style={{
          paddingBottom: "15px",
          clipPath: "inset(0 0 15px 0)",
        }}
      >
        {/* ... */}
      </div>
      {/* ... */}
    </div>
  );
}
```

The default scrollbar is hidden now:

<figure>
  <img src="https://miro.medium.com/v2/resize:fit:1024/format:webp/1*Y979e57UF1Qi7iRhnKGnsA.gif" alt="Custom scroll indicator is visible but does not move" />
  <figcaption>Custom scroll indicator is visible but does not move</figcaption>
</figure>

## Animating the custom scroll indicator

Of course, we also want it to display the actual slide selection and not just be stuck at the first position. Currently there seems to be no way to achieve this other than either using the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) or listening to [scroll events](https://developer.mozilla.org/en-US/docs/Web/API/Element/scroll_event) on the slider. We will go with the former, because it’s much cleaner (the latter would require to basically implement the Intersection Observer API behavior on our own) and arguably more performant, since it’s a native API:

```jsx
// src/components/image-slider.jsx

"use client";

import { useState, useRef, useEffect } from "react";

export default function ImageSlider({ images }) {
  const indicatorWidthPercent = images.length > 0 ? 100 / images.length : 100;

  const [currentSlideIndex, setCurrentSlideIndex] = useState(0);
  const sliderRef = useRef(null);

  useEffect(() => {
    const sliderCurrent = sliderRef.current;

    if (!sliderCurrent) {
      return;
    }

    // Find all the slides inside of the slider
    const slides = sliderCurrent.querySelectorAll("div");
    const slidesArray = Array.from(slides);

    // Wait until a slide is 50% visible, then find it's index in the array of
    // all slides and update the currentSlideIndex
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            const index = slidesArray.indexOf(entry.target);
            setCurrentSlideIndex(index);
          }
        });
      },
      {
        root: sliderCurrent,
        threshold: 0.5,
      }
    );
    slides.forEach((slide) => observer.observe(slide));

    return () => slides.forEach((slide) => observer.unobserve(slide));
  }, []);

  return (
    <div className="w-full">
      {/* Slider */}
      <div
        ref={sliderRef}
        className="w-full flex flex-row overflow-x-scroll snap-x snap-mandatory"
        style={{
          paddingBottom: "15px",
          clipPath: "inset(0 0 15px 0)",
        }}
      >
        {images.map((url) => {
          return (
            <div key={url} className="w-full flex-shrink-0 snap-start">
              <img src={url} />
            </div>
          );
        })}
      </div>

      {/* Scroll indicator */}
      <div className="w-full h-0.5 relative bg-gray-300">
        <div
          className="h-0.5 absolute top-0 left-0 bg-gray-500"
          style={{
            width: `${indicatorWidthPercent}%`,
            left: `${indicatorWidthPercent * currentSlideIndex}%`,
            transition: "left 150ms ease-in-out",
          }}
        />
      </div>
    </div>
  );
}
```

That should be it:

<figure>
  <img src="https://miro.medium.com/v2/resize:fit:1024/format:webp/1*Wlb75HJAIknNu0pREszTqg.gif" alt="The final result" />
  <figcaption>The final result</figcaption>
</figure>

## Considerations

Such clipping of the default scrollbar will obviously make the component unusable on a desktop without a mac touch pad, but adding a couple of navigation buttons on either side is not difficult. Another option would be listening to `click` events on the scroll indicator, getting the click position and scrolling to the according slide, but the indicator width might need to be adjusted, as it’s only `2px` now.

There might be accessibility issues if the scrollbar is clipped, but this again could be solved by adding a button to scroll to the left and another one to scroll to the right.

The component will break if JavaScript is disabled in the user’s browser. One way to mitigate this would be to add a `noscript` tag and un-clip the bottom of the slider in it via CSS: 
```css
padding-bottom: 0px !important; 
clip-path: none !important;
```

## Feedback

You can find the source code here:
{% embed https://github.com/prutya/tutorial-image-slider %}

If you have any feedback, please feel free to [submit an Issue](https://github.com/prutya/tutorial-image-slider/issues).

[My original blog post](https://www.prudkohliad.com/articles/slider-react-tailwind-intersection-observer-2023-05-26)