prudkohliad

How to implement a slider element using React, Tailwind CSS and Intersection Observer API


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:

The design
The design

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 and Next.js Styling With Tailwind, 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:

// 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 to create a list of placeholder image URLs:

// 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:

Slides stacked on top of each other
Slides stacked on top of each other

Horizontal slider look

Let’s make it look a bit more like a slider using Tailwind’s flexbox utilities. We need to add flex flex-row overflow-x-scroll classes to the slider and w-full flex-shrink-0 classes to each slide:

// 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:

// 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:

Slides look good but don’t snap
Slides look good but don’t snap

Snapping

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

Slides look good but don’t snap

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

// 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:

Slides do snap

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?

The little thing (scroll indicator)
The little thing (scroll indicator)

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):

// 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:

Double scrollbar
Double scrollbar

Hiding the default scrollbar

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

// 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:

Custom scroll indicator is visible but does not move

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 or listening to scroll events on the slider. We will go with the former because it’s much cleaner (the latter would require us to basically implement the Intersection Observer API behavior on our own) and arguably more performant, since it’s a native API:

// 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:

The final result

Considerations

Such clipping of the default scrollbar will make the component unusable on a desktop without a Mac touchpad. This can be mitigated by adding a couple of navigation buttons on either side. 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: paddingBottom: 0px !important; clip-path: none !important;

Feedback

You can find the source code in this GitHub repository. If you have any feedback, please feel free to submit an Issue.


Comments

🚧 Please enable JavaScript to use the comments feature

More posts