How to implement a slider element using React, Tailwind.css and Intersection Observer API
Anton Prudkohliad
Posted on May 8, 2024
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:
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:
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:
Snapping
Cool, this is definitely a slider, but there’s a catch: it won’t snap to pictures when you scroll:
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:
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?
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:
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:
// 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:
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 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:
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:
padding-bottom: 0px !important;
clip-path: none !important;
Feedback
You can find the source code here:
prutya / tutorial-image-slider
How to implement a slider element using React, Tailwind.css and Intersection Observer API
If you have any feedback, please feel free to submit an Issue.
Posted on May 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
May 8, 2024