DEV Community

Cover image for Building a Responsive Carousel Component in React: The Complete Guide
Sukhjinder Arora
Sukhjinder Arora

Posted on • Originally published at whatisweb.dev

Building a Responsive Carousel Component in React: The Complete Guide

This article was originally published at https://whatisweb.dev/building-a-responsive-carousel-component-in-react-the-complete-guide

In web design, a carousel component is used to display multiple images or pieces of content in a rotating or sliding manner. The carousel component is typically used to highlight featured products, showcase portfolios, present testimonials, or display news articles or blog posts.

While there are awesome libraries and components like Swiper that are used to create responsive and accessible carousels, it's still important to know how to create these components from scratch.

Creating these components from scratch will make you a better React developer and help you appreciate the work that goes into creating these libraries and components.

So without further ado, let’s get started. :)

Getting Started

Before starting, you must have npm installed on your computer, which comes bundled with Node.js which you can install from here.

Project Setup

Run the following commands to initialize the React application and install the required dependencies.

npm create vite@latest react-carousel --template react
cd react-carousel
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

Folder Structure

Before we start writing any code, let's create the folder structure required for the front-end application.

React Carousel Folder Structure

You can create the above directories with these commands.

cd react-carousel/src
mkdir components hooks
Enter fullscreen mode Exit fullscreen mode

(Optional) Adding fonts and styles

  • Add these fonts to the head section of the index.html file under the project’s root directory.
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
Enter fullscreen mode Exit fullscreen mode
  • Replace the CSS inside the index.css with the code below:
*::before,
*,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

.material-symbols-outlined {
  font-variation-settings: "FILL" 0, "wght" 600, "GRAD" 0, "opsz" 48;
}

body {
  margin: 0;
  font-family: "Poppins", sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background-color: #9EC6F3;
}
Enter fullscreen mode Exit fullscreen mode

Basic Carousel Layout

Now that we have created the initial set-up required to build and run the application, let’s start by creating the basic carousel layout — that is, the barebones HTML structure of the carousel component.

Note:— We will use CSS modules to style our React application. So for each component, we will also create a CSS or SCSS file for that component.

Create the Slider.jsx and Slider.module.css files inside the components/Slider folder and add the following code to them.

Slider.jsx

//Slider.jsx

import { Children } from "react";

import styles from "./Slider.module.css";

const Slider = ({
  items,
  children,
}) => {

  const sliderItems = items || Children.toArray(children);

  const sliderButtonHandler = (direction) => {
    if (direction === "forward") {
      // TODO: Add logic to slide to next item
    } else if (direction === "backward") {
      // TODO: Add logic to slide to previous item
    }
  };

  return (
    <div className={styles.slider}>
      <div className={styles.slidesContainer}>
        <div style={{ display: "flex" }}>
          {sliderItems.map((item, index) => {
            return (
              <div key={index}>
                {item}
              </div>
            );
          })}
        </div>
      </div>
      <button
        className={`${styles.slideButton} ${styles.slideButtonPrev}`}
        onClick={() => sliderButtonHandler("backward")}
      >
        <span
          className={`material-symbols-outlined ${styles.slideButtonIcon}`}
          style={{ letterSpacing: "4px" }}
        >
          arrow_back_ios_new
        </span>
      </button>
      <button
        className={`${styles.slideButton} ${styles.slideButtonNext}`}
        onClick={() => sliderButtonHandler("forward")}
      >
        <span className={`material-symbols-outlined ${styles.slideButtonIcon}`}>
          arrow_forward_ios
        </span>
      </button>
    </div>
  );
};

export default Slider;
Enter fullscreen mode Exit fullscreen mode

Slider.module.css

.slider {
    position: relative;
    max-width: 1300px;
    width: 100%;
}

.slidesContainer {
    overflow: hidden;
    width: 100%;
    margin-left: 8px;
}

.slideButton {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    height: 48px;
    width: 48px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    box-shadow: rgb(0 0 0 / 15%) 0px 4px 10px;
    border: none;
    cursor: pointer;
}

.slideButton:hover {
    box-shadow: rgb(0 0 0 / 25%) 0px 4px 10px;
}

.slideButtonPrev {
    left: -24px;
}

.slideButtonNext {
    right: -24px;
}

.slideButtonIcon {
    color: #353840;
    font-variation-settings: "FILL" 0, "wght" 700, "GRAD" 0, "opsz" 48;
}

@media screen and (max-width: 768px) {
    .slideButtonPrev {
        left: 0;
    }

    .slideButtonNext {
        right: 0;
    } 
}
Enter fullscreen mode Exit fullscreen mode

Modify the App.jsx file to include the newly created Slider component.

App.jsx

import Slider from './components/Slider/Slider';

import './App.css';

function App() {
  const sliderItems = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];

  return (
    <div>
          <Slider>
            {sliderItems.map((item, index) => (
                <div className="item" key={index}>{item}</div>
            ))}
          </Slider>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

App.css

.item {
  height: 310px;
  border-radius: 16px;
  background: #FFF1D5;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #604652;
  font-size: 102px;
}
Enter fullscreen mode Exit fullscreen mode

If you open the application in your browser, you’ll notice that the component we created doesn’t look anything like a carousel yet. But bear with me for a moment—we’ll soon add the necessary styles and functionality to turn it into a fully working carousel.

Before we dive into that, let me briefly explain the code we’ve written so far.

The Slider component accepts two props — items and children — either of which can be used to render the carousel content. We’ve also added two navigation buttons to move the slider forward and backward. Finally, we applied basic CSS styling to the component.

In the App component, we pass data to the Slider component by mapping over the sliderItems array.

Carousel Logic

Now that we have the layout of the carousel ready, let’s add the functionality needed to turn it into a working carousel.

Update the code inside the Slider.jsx and App.jsx with the code below.

Slider.jsx

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

import styles from "./Slider.module.css";

const Slider = ({
  slidesPerView: initialSliderPerView = 4,
  spaceBetween: initialSpaceBetween = 16,
  slidesPerGroup: initialSliderPerGroup = 4,  
  items,
  children,
}) => {
  // New Code
  const [slidesPerView, setSlidesPerView] = useState(initialSliderPerView);
  const [slidesPerGroup, setSlidesPerGroup] = useState(initialSliderPerGroup);
  const [spaceBetween, setSpaceBetween] = useState(initialSpaceBetween);

  const [sliderItemWidth, setSliderItemWidth] = useState(0);

  const sliderContainerRef = useRef(null);
  // New Code

  const sliderItems = items || Children.toArray(children);

  // New Code
  useEffect(() => {
    if (sliderContainerRef.current) {
      const sliderContainerWidth = sliderContainerRef.current.offsetWidth;

      const elements = sliderContainerRef.current.querySelectorAll('.slider-item');

      elements.forEach(el => {
        const sliderItemWidth = Math.ceil((sliderContainerWidth / slidesPerView) - spaceBetween);

        el.style.width = sliderItemWidth + 'px';

        Array.from(el.children).forEach(div => {
          div.style.width = sliderItemWidth + 'px';
        });

        setSliderItemWidth(sliderItemWidth);
      });
    }

  }, [slidesPerView, spaceBetween]);
  // New Code

  const sliderButtonHandler = (direction) => {
    if (direction === "forward") {
      // TODO: Add logic to slide to next item
    } else if (direction === "backward") {
      // TODO: Add logic to slide to previous item
    }
  };

  return (
    <div className={styles.slider}>
      <div className={styles.slidesContainer} ref={sliderContainerRef}>
        <div style={{ display: "flex" }}>
          {sliderItems.map((item, index) => {
            return (
              <div 
                className="slider-item" 
                key={index}
                style={{
                  marginRight: Number(spaceBetween)
                    ? `${spaceBetween}px`
                    : "0px",
                }}
              >
                {item}
              </div>
            );
          })}
        </div>
      </div>
      <button
        className={`${styles.slideButton} ${styles.slideButtonPrev}`}
        onClick={() => sliderButtonHandler("backward")}
      >
        <span
          className={`material-symbols-outlined ${styles.slideButtonIcon}`}
          style={{ letterSpacing: "4px" }}
        >
          arrow_back_ios_new
        </span>
      </button>
      <button
        className={`${styles.slideButton} ${styles.slideButtonNext}`}
        onClick={() => sliderButtonHandler("forward")}
      >
        <span className={`material-symbols-outlined ${styles.slideButtonIcon}`}>
          arrow_forward_ios
        </span>
      </button>
    </div>
  );
};

export default Slider;
Enter fullscreen mode Exit fullscreen mode

App.jsx

import Slider from './components/Slider/Slider';

import './App.css';

function App() {
  const sliderItems = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];

  return (
    <div className="flex-container">
          <Slider
            slidesPerView={4}
            slidesPerGroup={4}
            spaceBetween={16}
          >
            {sliderItems.map((item, index) => (
                <div className="item" key={index}>{item}</div>
            ))}
          </Slider>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

App.css

.flex-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
}

.item {
  height: 310px;
  border-radius: 16px;
  background: #FFF1D5;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #604652;
  font-size: 102px;
}
Enter fullscreen mode Exit fullscreen mode

React Carousel

If you open the application in your browser, you’ll see that the component is starting to look like a carousel, but it doesn’t function like one yet.

Before we make it fully interactive, let’s quickly review the code we’ve written so far.

The Slider component now accepts three additional props: slidesPerView, slidesPerGroup, and spaceBetween.

  • slidesPerView controls how many items are visible in the viewport at once.

  • slidesPerGroup defines how many items move with each navigation.

  • spaceBetween sets the spacing between each slide.

The component also uses a sliderItemWidth state variable to store the calculated width of each slide. This value is computed using the useEffect hook and a sliderContainerRef, which allow us to dynamically measure the container's width and divide it based on the number of slides per view.

Making it Functional

Let’s finally add the logic to turn it into a fully working carousel.

Update the Slider.jsx with the following code:

Slider.jsx

// Slider.jsx
import { Children, useState, useRef, useEffect } from "react";

import useInView from "../../hooks/useInView";

import styles from "./Slider.module.css";

const Slider = ({
  slidesPerView: initialSliderPerView = 4,
  spaceBetween: initialSpaceBetween = 16,
  slidesPerGroup: initialSliderPerGroup = 4,  
  items,
  children,
}) => {
  const [slidesPerView, setSlidesPerView] = useState(initialSliderPerView);
  const [slidesPerGroup, setSlidesPerGroup] = useState(initialSliderPerGroup);
  const [spaceBetween, setSpaceBetween] = useState(initialSpaceBetween);

  // New Code
  const [activeSlideIndex, setActiveSlideIndex] = useState(0);
  // New Code

  const [sliderItemWidth, setSliderItemWidth] = useState(0);

  const sliderContainerRef = useRef(null);

  // New Code
  const { inView: lastSliderItemInView, ref: lastSliderItemRef } = useInView({
    root: sliderContainerRef.current,
    threshold: 0.95,
  });

  const { inView: firstSliderItemInView, ref: firstSliderItemRef } = useInView({
    root: sliderContainerRef.current,
    threshold: 1.0,
  });
  // New Code

  const sliderItems = items || Children.toArray(children);

  useEffect(() => {
    if (sliderContainerRef.current) {
      const sliderContainerWidth = sliderContainerRef.current.offsetWidth;

      const elements = sliderContainerRef.current.querySelectorAll('.slider-item');

      elements.forEach(el => {
        const sliderItemWidth = Math.ceil((sliderContainerWidth / slidesPerView) - spaceBetween);

        el.style.width = sliderItemWidth + 'px';

        Array.from(el.children).forEach(elem => {
          elem.style.width = sliderItemWidth + 'px';
        });

        setSliderItemWidth(sliderItemWidth);
      });
    }

  }, [slidesPerView, spaceBetween]);

  // New Code
  const sliderButtonHandler = (direction) => {
    if (direction === "forward") {
      if (!lastSliderItemInView && activeSlideIndex < (sliderItems.length - slidesPerGroup)) {
        setActiveSlideIndex((prevIndex) => {
          if((sliderItems.length - prevIndex) < slidesPerGroup && sliderItems.length - prevIndex != 0) {
            return sliderItems.length;
          }
          return prevIndex + slidesPerGroup;
        });
      }
    } else if (direction === "backward") {
      if (!firstSliderItemInView && activeSlideIndex > 0) {
        setActiveSlideIndex((prevIndex) => {
          if(prevIndex < slidesPerGroup) {
            return 0;
          }
          return prevIndex - slidesPerGroup;
        });
      }
    }
  };

  const setSliderItemRef = (index, sliderItemsArray) => {
    if (index === 0) {
      return firstSliderItemRef;
    }
    if (index === sliderItemsArray.length - 1) {
      return lastSliderItemRef;
    }
    return null;
  };
  // New Code

  return (
    <div className={styles.slider}>
      <div className={styles.slidesContainer} ref={sliderContainerRef}>
        <div style={{ 
          display: "flex",
          transition: "all 0.5s ease-in-out",
          transform: `translateX(${
            (sliderItemWidth + spaceBetween) * activeSlideIndex * -1
          }px)`,

          }}
        >
          {sliderItems.map((item, index, array) => {
            return (
              <div 
                className="slider-item" 
                key={index}
                ref={setSliderItemRef(index, array)}
                style={{
                  marginRight: Number(spaceBetween)
                    ? `${spaceBetween}px`
                    : "0px",
                }}
              >
                {item}
              </div>
            );
          })}
        </div>
      </div>
      <button
        className={`${styles.slideButton} ${styles.slideButtonPrev}`}
        onClick={() => sliderButtonHandler("backward")}
      >
        <span
          className={`material-symbols-outlined ${styles.slideButtonIcon}`}
          style={{ letterSpacing: "4px" }}
        >
          arrow_back_ios_new
        </span>
      </button>
      <button
        className={`${styles.slideButton} ${styles.slideButtonNext}`}
        onClick={() => sliderButtonHandler("forward")}
      >
        <span className={`material-symbols-outlined ${styles.slideButtonIcon}`}>
          arrow_forward_ios
        </span>
      </button>
    </div>
  );
};

export default Slider;
Enter fullscreen mode Exit fullscreen mode

The updated code uses a custom useInView hook, which checks whether a slide is currently visible in the viewport.

Create a new file named useInView.js inside the hooks directory, and add the following code to it:

useInView.js

// useInView.js
import { useCallback, useEffect, useRef, useState } from "react";

const useInView = (
  options = {
    root: null,
    rootMargin: "0px",
    threshold: 1.0,
  }
) => {
  const [inView, setInView] = useState(false);
  const targetRef = useRef(null);

  const inViewRef = useCallback((node) => {
    if (node) {
      targetRef.current = node;
    }
  }, []);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          setInView(true);
        } else {
          setInView(false);
        }
      });
    }, options);
    if (targetRef.current) {
      observer.observe(targetRef.current);
    }
  }, [options]);

  return { ref: targetRef, inView, inViewRef };
};

export default useInView;
Enter fullscreen mode Exit fullscreen mode

We’ve made quite a few changes to the code to make the carousel interactive. Let’s quickly review the changes one by one:

  • We introduced a new state variable, activeSlideIndex, which keeps track of the index of the leftmost visible slide in the viewport.

  • We added a custom hook, useInView, that detects when a slide enters the viewport. This hook helps determine when the first or last slide becomes visible.

  • The sliderButtonHandler function has been updated to handle carousel navigation. It adjusts the activeSlideIndex based on whether the user clicks the forward or backward button.

  • Finally, we applied CSS transform to the slider container using the translateX function, which uses the activeSlideIndex to move the carousel forward or backward.

AutoPlay & Loop

Now that we’ve implemented basic navigation to move the carousel forward and backward using the navigation buttons, let’s make it more dynamic by adding autoplay and looping functionality.

These features will allow the carousel to automatically move to the next slide at regular intervals and loop back to the first slide once it reaches the end.

Update the Slider.jsx with the following code:

// Slider.jsx
import { Children, useState, useRef, useEffect } from "react";

import styles from "./Slider.module.css";
import useInView from "../../hooks/useInView";

const Slider = ({
  slidesPerView: initialSliderPerView = 4,
  spaceBetween: initialSpaceBetween = 16,
  slidesPerGroup: initialSliderPerGroup = 4,  
  items,
  children,
  loop,
  autoPlay = false,
  autoPlayInterval = 2000,
}) => {
  const [slidesPerView, setSlidesPerView] = useState(initialSliderPerView);
  const [slidesPerGroup, setSlidesPerGroup] = useState(initialSliderPerGroup);
  const [spaceBetween, setSpaceBetween] = useState(initialSpaceBetween);

  // New Code
  const [activeSlideIndex, setActiveSlideIndex] = useState(
    loop ? slidesPerView : 0
  );
  const [transitionEnabled, setTransitionEnabled] = useState(false);
  // New Code

  const [sliderItemWidth, setSliderItemWidth] = useState(0);

  const sliderContainerRef = useRef(null);

  const { inView: lastSliderItemInView, ref: lastSliderItemRef } = useInView({
    root: sliderContainerRef.current,
    threshold: 0.95,
  });

  const { inView: firstSliderItemInView, ref: firstSliderItemRef } = useInView({
    root: sliderContainerRef.current,
    threshold: 1.0,
  });


  items = items || Children.toArray(children);

  // New Code
  const sliderItems = loop
    ? [
        ...items.slice(-slidesPerView),
        ...items,
        ...items.slice(0, slidesPerView),
      ]
    : items;
  // New Code

  useEffect(() => {
    if (sliderContainerRef.current) {
      const sliderContainerWidth = sliderContainerRef.current.offsetWidth;

      const elements = sliderContainerRef.current.querySelectorAll('.slider-item');

      elements.forEach(el => {
        const sliderItemWidth = Math.ceil((sliderContainerWidth / slidesPerView) - spaceBetween);

        el.style.width = sliderItemWidth + 'px';

        Array.from(el.children).forEach(div => {
          div.style.width = sliderItemWidth + 'px';
        });

        setSliderItemWidth(sliderItemWidth);
      });
    }

  }, [slidesPerView, spaceBetween]);

  // New Code
  useEffect(() => {
    setTimeout(() => {
      setTransitionEnabled(true);
    }, 100);
  }, [firstSliderItemRef]);


  useEffect(() => {
    let intervalID;
    if (loop && autoPlay) {
      intervalID = setInterval(() => {
        if (
          activeSlideIndex === slidesPerGroup ||
          activeSlideIndex === items.length
        ) {
          setTransitionEnabled(true);
        }
        setActiveSlideIndex((prevIndex) => prevIndex + slidesPerGroup);
      }, autoPlayInterval);
    }
    return () => {
      if (intervalID) {
        clearInterval(intervalID);
      }
    };
  }, [
    loop,
    slidesPerGroup,
    activeSlideIndex,
    items.length,
    autoPlay,
    autoPlayInterval,
  ]);
  // New Code

  // New Code
  const sliderButtonHandler = (direction) => {
    if (
      activeSlideIndex === slidesPerGroup ||
      activeSlideIndex === items.length
    ) {
      setTransitionEnabled(true);
    }
    if (direction === "forward") {
      if (loop || (!loop && !lastSliderItemInView && activeSlideIndex < (items.length - slidesPerGroup))) {
        setActiveSlideIndex((prevIndex) => {
          if((items.length - prevIndex) < slidesPerGroup && items.length - prevIndex != 0) {
            return items.length;
          }
          return prevIndex + slidesPerGroup;
        });
      }
    } else if (direction === "backward") {
      if (loop || (!loop && !firstSliderItemInView && activeSlideIndex > 0)) {
        setActiveSlideIndex((prevIndex) => {
          if(prevIndex < slidesPerGroup) {
            return 0;
          }
          return prevIndex - slidesPerGroup;
        });
      }
    }
  };

  const handleTransitionEnd = () => {
    if (loop) {
      if (activeSlideIndex > items.length) {
        setTransitionEnabled(false);
        setActiveSlideIndex(slidesPerGroup);
      } else if (activeSlideIndex === 0) {
        setTransitionEnabled(false);
        setActiveSlideIndex(items.length);
      }
    }
  };
  // New Code

  const setSliderItemRef = (index, sliderItemsArray) => {
    if (loop && index === 0) {
      return firstSliderItemRef;
    }
    if (!loop) {
      if (index === 0) {
        return firstSliderItemRef;
      }
      if (index === sliderItemsArray.length - 1) {
        return lastSliderItemRef;
      }
    }
    return null;
  };

  return (
    <div className={styles.slider}>
      <div className={styles.slidesContainer} ref={sliderContainerRef}>
        <div 
          onTransitionEnd={handleTransitionEnd}
          style={{
            display: "flex",
            transition: !transitionEnabled ? "none" : "all 0.5s ease-in-out",
            transform: `translateX(${
              (sliderItemWidth + spaceBetween) * activeSlideIndex * -1
            }px)`,
          }}
        >
          {sliderItems.map((item, index, array) => {
            return (
              <div 
                className="slider-item" 
                key={index}
                ref={setSliderItemRef(index, array)}
                style={{
                  marginRight: Number(spaceBetween)
                    ? `${spaceBetween}px`
                    : "0px",
                }}
              >
                {item}
              </div>
            );
          })}
        </div>
      </div>
      <button
        className={`${styles.slideButton} ${styles.slideButtonPrev}`}
        onClick={() => sliderButtonHandler("backward")}
      >
        <span
          className={`material-symbols-outlined ${styles.slideButtonIcon}`}
          style={{ letterSpacing: "4px" }}
        >
          arrow_back_ios_new
        </span>
      </button>
      <button
        className={`${styles.slideButton} ${styles.slideButtonNext}`}
        onClick={() => sliderButtonHandler("forward")}
      >
        <span className={`material-symbols-outlined ${styles.slideButtonIcon}`}>
          arrow_forward_ios
        </span>
      </button>
    </div>
  );
};

export default Slider;
Enter fullscreen mode Exit fullscreen mode

We have made a lot of changes to the component, let’s go through them one by one:

  • The slider component now accepts three more props — loop, autoPlay and autoPlayInterval.

  • If looping is enabled, sliderItems becomes:

    const sliderItems = [
      ...items.slice(-slidesPerView),
      ...items,
      ...items.slice(0, slidesPerView),
    ];
    
  • This allows the slider to simulate an infinite loop. When the user scrolls to the "end" or "start" of the carousel, the fake extra items allow a seamless visual transition back to the start or end, and the slider can be quickly repositioned behind the scenes.

  • The initial value of activeSlideIndex is set to the value of slidesPerView when looping is enabled.

  • The sliderButtonHandler function has been updated to handle looping behavior, adjusting the activeSlideIndex appropriately when navigating forward or backward

  • A new useEffect hook has been introduced to implement the autoplay functionality. It automatically updates the activeSlideIndex at a specified interval, moving the carousel forward without user interaction.

  • A handleTransitionEnd function has been added to temporarily remove the CSS transition from the slider container once the carousel reaches the end.

  • Another useEffect hook has been introduced that re-enables the transition when the first item becomes visible again, ensuring a smooth loop back to the beginning.

Finally update the App.jsx to enable autoplay and looping:

import Slider from './components/Slider/Slider';

import './App.css';

function App() {
  const sliderItems = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];

  return (
    <div className="flex-container">
          <Slider
            slidesPerView={4}
            slidesPerGroup={4}
            spaceBetween={16}
            autoPlay={true}
            autoPlayInterval={2000}
            loop={true}
          >
            {sliderItems.map((item, index) => (
                <div className="item" key={index}>{item}</div>
            ))}
          </Slider>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Responsive Design

That was a lot of code we wrote, but there’s still one important piece of functionality that’s still missing — making the carousel responsive. Let’s finally add the code required to make it responsive across devices of different resolutions.

Update the Slider.jsx with the following code.

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

import useInView from "../../hooks/useInView";
import useWindowDimensions from "../../hooks/useWindowDimensions";

import styles from "./Slider.module.css";

const Slider = ({
  slidesPerView: initialSliderPerView,
  spaceBetween: initialSpaceBetween,
  slidesPerGroup: initialSliderPerGroup,
  loop,
  breakpoints,
  items,
  children,
  autoPlay = false,
  autoPlayInterval = 2000,
}) => {
  const [slidesPerView, setSlidesPerView] = useState(initialSliderPerView);
  const [slidesPerGroup, setSlidesPerGroup] = useState(initialSliderPerGroup);
  const [spaceBetween, setSpaceBetween] = useState(initialSpaceBetween);
  const [activeSlideIndex, setActiveSlideIndex] = useState(
    loop ? slidesPerView : 0
  );
  const [transitionEnabled, setTransitionEnabled] = useState(false);
  const [sliderItemWidth, setSliderItemWidth] = useState(0);
  const sliderContainerRef = useRef(null);

  // New Code
  const { width: deviceWidth } = useWindowDimensions();
  // New Code

  const { inView: lastSliderItemInView, ref: lastSliderItemRef } = useInView({
    root: sliderContainerRef.current,
    threshold: 0.95,
  });

  const { inView: firstSliderItemInView, ref: firstSliderItemRef } = useInView({
    root: sliderContainerRef.current,
    threshold: 1.0,
  });

  items = items || Children.toArray(children);

  useEffect(() => {
    if (sliderContainerRef.current) {
      const sliderContainerWidth = sliderContainerRef.current.offsetWidth;

      const elements = sliderContainerRef.current.querySelectorAll('.slider-item');

      elements.forEach(el => {
        const sliderItemWidth = Math.ceil((sliderContainerWidth / slidesPerView) - spaceBetween);

        el.style.width = sliderItemWidth + 'px';

        Array.from(el.children).forEach(div => {
          div.style.width = sliderItemWidth + 'px';
        });

        setSliderItemWidth(sliderItemWidth);
      });
    }

  }, [slidesPerView, spaceBetween]);

  useEffect(() => {
    setTimeout(() => {
      setTransitionEnabled(true);
    }, 100);
  }, [firstSliderItemRef]);

 // New Code
  useEffect(() => {
    if (breakpoints) {
      Object.keys(breakpoints).forEach((breakpoint) => {
        if (Number(breakpoint) && deviceWidth >= Number(breakpoint)) {
          setSlidesPerView(
            (prev) => breakpoints[breakpoint].slidesPerView || prev
          );
          setSlidesPerGroup(
            (prev) => breakpoints[breakpoint].slidesPerGroup || prev
          );
          setSpaceBetween(
            (prev) => breakpoints[breakpoint].spaceBetween || prev
          );
          if(loop) {
            setActiveSlideIndex((prev) => breakpoints[breakpoint].slidesPerView || prev);
          }
        }
      });
    }
  }, [deviceWidth, breakpoints, loop]);
  // New Code

  useEffect(() => {
    let intervalID;
    if (loop && autoPlay) {
      intervalID = setInterval(() => {
        if (
          activeSlideIndex === slidesPerGroup ||
          activeSlideIndex === items.length
        ) {
          setTransitionEnabled(true);
        }
        setActiveSlideIndex((prevIndex) => prevIndex + slidesPerGroup);
      }, autoPlayInterval);
    }
    return () => {
      if (intervalID) {
        clearInterval(intervalID);
      }
    };
  }, [
    loop,
    slidesPerGroup,
    activeSlideIndex,
    items.length,
    autoPlay,
    autoPlayInterval,
  ]);

  const sliderButtonHandler = (direction) => {
    if (
      activeSlideIndex === slidesPerGroup ||
      activeSlideIndex === items.length
    ) {
      setTransitionEnabled(true);
    }
    if (direction === "forward") {
      if (loop || (!loop && !lastSliderItemInView && activeSlideIndex < (items.length - slidesPerGroup))) {
        setActiveSlideIndex((prevIndex) => {
          if((items.length - prevIndex) < slidesPerGroup && items.length - prevIndex != 0) {
            return items.length;
          }
          return prevIndex + slidesPerGroup;
        });
      }
    } else if (direction === "backward") {
      if (loop || (!loop && !firstSliderItemInView && activeSlideIndex > 0)) {
        setActiveSlideIndex((prevIndex) => {
          if(prevIndex < slidesPerGroup) {
            return 0;
          }
          return prevIndex - slidesPerGroup;
        });
      }
    }
  };

  const handleTransitionEnd = () => {
    if (loop) {
      if (activeSlideIndex > items.length) {
        setTransitionEnabled(false);
        setActiveSlideIndex(slidesPerGroup);
      } else if (activeSlideIndex === 0) {
        setTransitionEnabled(false);
        setActiveSlideIndex(items.length);
      }
    }
  };

  const sliderItems = loop
    ? [
        ...items.slice(-slidesPerView),
        ...items,
        ...items.slice(0, slidesPerView),
      ]
    : items;

  const setSliderItemRef = (index, sliderItemsArray) => {
    if (loop && index === 0) {
      return firstSliderItemRef;
    }
    if (!loop) {
      if (index === 0) {
        return firstSliderItemRef;
      }
      if (index === sliderItemsArray.length - 1) {
        return lastSliderItemRef;
      }
    }
    return null;
  };

  return (
    <div className={styles.slider}>
      <div className={styles.slidesContainer} ref={sliderContainerRef}>
        <div
          onTransitionEnd={handleTransitionEnd}
          style={{
            display: "flex",
            transition: !transitionEnabled ? "none" : "all 0.5s ease-in-out",
            transform: `translateX(${
              (sliderItemWidth + spaceBetween) * activeSlideIndex * -1
            }px)`,
            marginBottom: "3px",
          }}
        >
          {sliderItems.map((item, index, array) => {
            return (
              <div
                className="slider-item" 
                key={index}
                ref={setSliderItemRef(index, array)}
                style={{
                  marginRight: Number(spaceBetween)
                    ? `${spaceBetween}px`
                    : "0px",
                }}
              >
                {item}
              </div>
            );
          })}
        </div>
      </div>
      <button
        className={`${styles.slideButton} ${styles.slideButtonPrev}`}
        onClick={() => sliderButtonHandler("backward")}
      >
        <span
          className={`material-symbols-outlined ${styles.slideButtonIcon}`}
          style={{ letterSpacing: "4px" }}
        >
          arrow_back_ios_new
        </span>
      </button>
      <button
        className={`${styles.slideButton} ${styles.slideButtonNext}`}
        onClick={() => sliderButtonHandler("forward")}
      >
        <span className={`material-symbols-outlined ${styles.slideButtonIcon}`}>
          arrow_forward_ios
        </span>
      </button>
    </div>
  );
};

export default Slider;
Enter fullscreen mode Exit fullscreen mode

Create a new file named useWindowDimensions.js inside the hooks directory and add the following code to it:

useWindowDimensions.js

import { useState, useEffect } from "react";

function getWindowDimensions() {
  const { innerWidth: width, innerHeight: height } = window;
  return {
    width,
    height,
  };
}

export default function useWindowDimensions() {
  const [windowDimensions, setWindowDimensions] = useState(
    getWindowDimensions()
  );

  useEffect(() => {
    function handleResize() {
      setWindowDimensions(getWindowDimensions());
    }

    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return windowDimensions;
}
Enter fullscreen mode Exit fullscreen mode

Let’s quickly go through the changes we made one by one:

  • The updated code uses another custom useWindowDimensions hook, which returns the height and width of the browser window both when it is first initialized and whenever the window is resized.

  • The component now accepts one additional prop — breakpoints, an object that defines how many items should be visible in the carousel based on the device’s screen width.

  • Each key in the breakpoints object (e.g., 320, 768, 1366) represents a minimum viewport width (in pixels). The corresponding value defines the layout configuration at that width.

  • A new useEffect hook has been added to dynamically update the slidesPerView, slidesPerGroup, and spaceBetween values according to the current screen width, if the breakpoints prop is provided

Finally update the App.jsx to enable responsive behavior:

import Slider from './components/Slider/Slider';
import './App.css';

function App() {
  const sliderItems = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];

  return (
    <div className="flex-container">
          <Slider
            spaceBetween={16}
            slidesPerView={4}
            slidesPerGroup={4}
            loop={true}
            autoPlay={true}
            autoPlayInterval={4000}
            breakpoints={{
              320: {
                slidesPerView: 1,
                slidesPerGroup: 1,
              },
              1366: {
                slidesPerView: 4,
                slidesPerGroup: 4,
                spaceBetween: 18
              },
            }}
          >
            {sliderItems.map((item, index) => (
                <div className="item" key={index}>{item}</div>
            ))}
          </Slider>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Final Result

Source Code

You can download the source code from my GitHub repo:

GitHub logo SukhjinderArora / react-carousel

A responsive React carousel component

Conclusion

In this article, we walked through building a fully functional, customizable, and responsive carousel component in React from scratch. Starting with a basic layout, we incrementally added key features like navigation controls, autoplay and loop.

Finally, we implemented responsive behavior using the breakpoints prop, allowing the carousel to adapt seamlessly to different screen sizes and device resolutions.

By now, you should have a solid understanding of how to build a flexible carousel component that can be reused and extended in your own projects.

That’s it and hope you found this article helpful! Please feel free to comment below and ask anything, suggest feedback or just chat. You can also follow me on Hashnode, Medium and Twitter. Cheers! ⭐️

Top comments (0)