Building a Responsive Carousel Component in React

Learn how to build a fully functional, responsive carousel component from scratch in React with autoplay, looping, and responsive design features.

Building a Responsive Carousel Component in React

A carousel component is a crucial UI element used to display multiple images or pieces of content in a rotating or sliding manner. It's commonly used to highlight featured products, showcase portfolios, present testimonials, or display news articles and blog posts.

While there are excellent libraries like Swiper that provide ready-to-use carousel components, understanding how to build one from scratch will make you a better React developer and help you appreciate the complexity behind these libraries.

Table of Contents

Getting Started

Before we begin, ensure you have Node.js and npm installed on your computer. You can download Node.js from nodejs.org.

Project Setup

Let's start by creating a new React project using Vite:

npm create vite@latest react-carousel --template react
cd react-carousel
npm install
npm run dev

Folder Structure

Create the following folder structure for your project:

react-carousel/
├── src/
│   ├── components/
│   │   └── Slider/
│   │       ├── Slider.jsx
│   │       └── Slider.module.css
│   ├── hooks/
│   │   ├── useInView.js
│   │   └── useWindowDimensions.js
│   ├── App.jsx
│   ├── App.css
│   └── index.css

You can create these directories with:

cd react-carousel/src
mkdir components hooks
cd components
mkdir Slider

Adding Fonts and Styles

Add these fonts to the <head> section of your index.html:

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

Replace the CSS in index.css with:

*::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;
}

Let's start by creating the basic carousel layout with the HTML structure and navigation buttons.

Slider Component

Create Slider.jsx in the components/Slider folder:

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;

Slider Styles

Create Slider.module.css in the same folder:

.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;
    } 
}

App Component

Update your 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;

App Styles

Update App.css:

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

At this point, you'll see a basic layout with navigation buttons, but the carousel won't function yet. Let's add the logic to make it work.

Now let's add the functionality to make the carousel interactive by calculating slide widths and handling navigation.

Updated Slider Component

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,
}) => {
  const [slidesPerView, setSlidesPerView] = useState(initialSliderPerView);
  const [slidesPerGroup, setSlidesPerGroup] = useState(initialSliderPerGroup);
  const [spaceBetween, setSpaceBetween] = useState(initialSpaceBetween);
  const [sliderItemWidth, setSliderItemWidth] = useState(0);
  const sliderContainerRef = useRef(null);
  
  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(div => {
          div.style.width = sliderItemWidth + 'px';
        });

        setSliderItemWidth(sliderItemWidth);
      });
    }
  }, [slidesPerView, spaceBetween]);

  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;

Updated App Component

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;

Updated App Styles

.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;
}

Making it Functional

Now let's add the actual sliding functionality using state management and CSS transforms.

Custom Hook: useInView

Create useInView.js in the hooks folder:

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;

Updated Slider Component with Functionality

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);
  const [activeSlideIndex, setActiveSlideIndex] = useState(0);
  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,
  });
  
  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]);

  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;
  };

  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;

AutoPlay & Loop

Let's add autoplay functionality and infinite looping to make the carousel more dynamic.

Updated Slider Component with AutoPlay and Loop

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,
  loop,
  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);

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

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

  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]);

  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 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;

Updated App Component

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;

Responsive Design

Now let's make the carousel responsive by adding breakpoint support and window dimension tracking.

Custom Hook: useWindowDimensions

Create useWindowDimensions.js in the hooks folder:

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;
}

Final Responsive Slider Component

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

  const { width: deviceWidth } = useWindowDimensions();

  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]);

  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]);

  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;

Final App Component

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;

Complete Implementation

You now have a fully functional, responsive carousel component with the following features:

Key Features

  1. Responsive Design: Adapts to different screen sizes using breakpoints
  2. AutoPlay: Automatically slides to the next item at specified intervals
  3. Infinite Loop: Seamlessly loops back to the beginning when reaching the end
  4. Smooth Transitions: CSS transitions for smooth sliding animations
  5. Navigation Controls: Previous/Next buttons for manual navigation
  6. Customizable: Configurable slides per view, spacing, and grouping

Props Reference

PropTypeDefaultDescription
slidesPerViewnumber4Number of slides visible at once
slidesPerGroupnumber4Number of slides to move per navigation
spaceBetweennumber16Space between slides in pixels
loopbooleanfalseEnable infinite looping
autoPlaybooleanfalseEnable automatic sliding
autoPlayIntervalnumber2000Interval for autoplay in milliseconds
breakpointsobject-Responsive breakpoint configuration
itemsarray-Array of items to display
childrenReactNode-React children to display

Breakpoints Configuration

breakpoints={{
  320: {
    slidesPerView: 1,
    slidesPerGroup: 1,
  },
  768: {
    slidesPerView: 2,
    slidesPerGroup: 2,
  },
  1366: {
    slidesPerView: 4,
    slidesPerGroup: 4,
    spaceBetween: 18
  },
}}

This carousel component provides a solid foundation that you can extend with additional features like pagination dots, touch/swipe support, or keyboard navigation. The modular approach makes it easy to maintain and customize for different use cases.

Summary

Building a carousel component from scratch teaches valuable lessons about:

  • State Management: Handling complex state transitions
  • DOM Manipulation: Direct DOM access for measurements
  • CSS Transforms: Using transforms for smooth animations
  • Responsive Design: Adapting to different screen sizes
  • Custom Hooks: Creating reusable logic with hooks
  • Performance: Optimizing re-renders and transitions

This implementation demonstrates modern React patterns and provides a production-ready carousel component that you can use in your projects or as a learning reference for building similar components.