Creating a Reusable Carousel Component in React

In this article, I will be showcasing how we can create a simple carousel component without need of any external library. I will create a barebones carousel component, to which we can apply styling based on our requirement.

A carousel is used to display content in a slideshow like format. The content can be anything like images or cards. It’s main purpose is to display a bunch of content of similar type in a confined space. It can be used to display banners or popular products on a website and many more. The main advantage is that it saves space while making the UI look and feel dynamic.

Basically, a carousel is an element which arranges the content inside it in a vertical or horizontal manner. It can accept a long list of items, but only shows a limited amount at a time. The remaining items are carefully hidden by manipulating the overflow properties of the parent container.

Prerequistes

To continue further, you will need the knowledge of React. Additionally, I will be using tailwindCss and typescript for the tutorial.

Let’s get started

Setup

First get your basic react project setup using any of the tools you like, I will be using vite. Once, the setup is done we will add tailwind for styling and lucide-react for icons. Also, add a carousel.tsx file inside the components folder.

I will also be using the cn function from shadcn/ui which uses tailwind-merge and clsx libraries for handling and merging tailwind classes. You can easily google it or use AI to learn more about it.

This is where we will be working. With this the project structure after the setup is done.

└── src/
    ├── App.css
    ├── App.tsx
    ├── index.css
    ├── main.tsx
    ├── vite-env.d.ts
    └── components/
        └── carousel.tsx
    └── lib/
        └── utils.ts

Initially, we will add a simple div element with properties flex and allowing it to overflow. This will let us add as many elements as we want in a horizontal alignment. The snap classes will help in properly snapping the children at a specific point.

import React from "react";

interface CarouselProps {
  children: React.ReactNode;
}

function Carousel({ children }: CarouselProps) {
  return <div className="w-full overflow-x-scroll flex snap-x snap-mandatory">{children}</div>;
}

Carousel.displayName = "Carousel";

export default Carousel;

In the same manner, we will also add a CarouselItem component. This will be the individual slide / images in our carousel component.

import { cn } from "../lib/util";

function CarouselItem({ children, className }: React.ComponentProps<"div">) {
  return <div className={cn("snap-start", className)}>{children}</div>;
}

CarouselItem.displayName = "CarouselItem";

export { CarouselItem };

Usage

This is how we can use the component.

    <div className="m-4">
      <Carousel>
        <CarouselItem className="min-w-[500px] p-4 border-2 mx-5">
          Slide 1
        </CarouselItem>
        <CarouselItem className="min-w-[500px] p-4 border-2 mx-5">
          Slide 2
        </CarouselItem>
        <CarouselItem className="min-w-[500px] p-4 border-2 mx-5">
          Slide 3
        </CarouselItem>
        <CarouselItem className="min-w-[500px] p-4 border-2 mx-5">
          Slide 4
        </CarouselItem>
        <CarouselItem className="min-w-[500px] p-4 border-2 mx-5">
          Slide 5
        </CarouselItem>
      </Carousel>
    </div>

Demo:

Currently, we can see that slides are snapping at the start position correctly but we are required to scroll through the slides using our mouse pointer. That is not the intended behaviour for a carousel. Next, we will work on adding the expected functionality to this component.

Adding functionality

First we will hide the scrollbar. For this we need to use CSS:

.hide-scrollbar {
  scrollbar-width: none;
  /* Firefox */
  -ms-overflow-style: none;
  /* IE and Edge */
}

.hide-scrollbar::-webkit-scrollbar {
  display: none;
  /* Chrome, Safari, Opera */
}

add this hide-scrollbar class to our parent Carousel component.

function Carousel({ children }: CarouselProps) {
  return <div className="w-full overflow-x-scroll flex snap-x snap-mandatory hide-scrollbar">{children}</div>;
}

Now, that scrollbar is hidden, we need to add the following things:

  1. Adding slides per view

  2. Adding prev and next buttons

  3. Getting active slide

Ok then let’s get started!

Adding slides per view

Till now we have passed a min-height to the carousel-item but this approach does not garuntee a fixed amount of slides on different size of screens. For this we will need to use some javascript magic.

First we will need to update our props to accepts slides, this will specify how many number of slides we want to specify in our carousel at any given time. Then, we will need a function to calculate the width for the carousel-items. Here, is the code for it:

const containerWidth = parentContainer.current.offsetWidth;

const gap = 32; // gap between any two slides
const totalGap = gap * (slides - 1); // total width that will occupied by all gaps between between any two slides

const availableWidth = containerWidth - totalGap; 
const width = availableWidth / slides; // width to apply to one slide

The code block calculates width to be applied to each carousel-item from the total width of the carousel component. For now, I have kept the gap as 32px but you can make it dynamic, by accepting it in props.

Here, is the code for updated carousel component:

interface CarouselProps {
  children: React.ReactNode;
  slides: number;
}

function Carousel({ children, slides }: CarouselProps) {
  const parentContainer = useRef<HTMLDivElement>(null);
  const [itemWidth, setItemWidth] = useState(0);

  useEffect(() => {
    if (parentContainer.current && slides) {
      const containerWidth = parentContainer.current.offsetWidth;

      const gap = 32; // gap between any two slides
      const totalGap = gap * (slides - 1); // total width that will occupied by all gaps between any two slides

      const availableWidth = containerWidth - totalGap; 
      const width = availableWidth / slides; // width to apply to one slide

      setItemWidth(width);
    }
  }, [slides, children]);

  return (
    <div
      ref={parentContainer}
      className="w-full overflow-x-scroll flex snap-x snap-mandatory hide-scrollbar"
      style={{
        gap: 32,
      }}
    >
      {React.Children.map(children, (child) =>
        React.isValidElement(child) ? (
          <div style={{ minWidth: `${itemWidth}px` }}>{child}</div>
        ) : null
      )}
    </div>
  );
}

We have created a reference to the parent-container, which will be used to get the width of the container. The useEffect calculates the width for each individual slide and stores it in a itemWidth state variable.

Adding prev and next buttons

Now, let’s add controls for going to previous and next slides. For this, we will only need to programatically scroll forward and backward in our container.

  const scrollBy = (direction: number) => {
    if (parentContainer.current && itemWidth) {
      parentContainer.current.scrollTo({
        left: parentContainer.current.scrollLeft + direction * itemWidth,
        behavior: "smooth",
      });
    }
  };

The scrollBy function will scroll in both directions based on the width of our slides. If we pass positive number it will scroll to right and if negative number is passed it will scroll to left.

Now, to create controls, I will use the hook useImperativeHandle, this hook is used to extend the functionality of ref. You can read more about it on the official react docs. And we will also need to wrap our carousel component in a forwardRef. This will enable us to use the prev and next methods outside of our component.

This will be the updated code:

export interface SliderRefType {
  next: () => void;
  prev: () => void;
}

interface CarouselProps {
  children: React.ReactNode;
  slides: number;
}

const Carousel = forwardRef<SliderRefType, CarouselProps>(
  ({ children, slides }, ref) => {
    const parentContainer = useRef<HTMLDivElement>(null);
    const [itemWidth, setItemWidth] = useState(0);

    useEffect(() => {
      if (parentContainer.current && slides) {
        const containerWidth = parentContainer.current.offsetWidth;

        const gap = 32; // gap between any two slides
        const totalGap = gap * (slides - 1); // total width that will occupied by all gaps between any two slides

        const availableWidth = containerWidth - totalGap;
        const width = availableWidth / slides; // width to apply to one slide

        setItemWidth(width);
      }
    }, [slides, children]);

    useImperativeHandle(ref, () => ({
      next: () => scrollBy(1),
      prev: () => scrollBy(-1),
    }));

    const scrollBy = (direction: number) => {
      if (parentContainer.current && itemWidth) {
        parentContainer.current.scrollTo({
          left: parentContainer.current.scrollLeft + direction * itemWidth,
          behavior: "smooth",
        });
      }
    };

    return (
      <div
        ref={parentContainer}
        className="w-full overflow-x-scroll flex snap-x snap-mandatory hide-scrollbar"
        style={{
          gap: 32,
        }}
      >
        {React.Children.map(children, (child) =>
          React.isValidElement(child) ? (
            <div style={{ minWidth: `${itemWidth}px` }}>{child}</div>
          ) : null
        )}
      </div>
    );
  }
);

Inside the useImperativeHandle, we are passing two functions prev and next which we will use in our previous and next buttons.

Here, is the usage of the updated component:

function App() {
  const carouselRef = useRef<CarouselRefType>(null);

  const handlePrev = () => {
    if (!carouselRef.current) return;

    carouselRef.current.prev();
  };

  const handleNext = () => {
    if (!carouselRef.current) return;

    carouselRef.current.next();
  };

  return (
    <div className="m-4">
      <Carousel ref={carouselRef} slides={3}>
        <CarouselItem className="p-4 border-2">Slide 1</CarouselItem>
        <CarouselItem className="p-4 border-2">Slide 2</CarouselItem>
        <CarouselItem className="p-4 border-2">Slide 3</CarouselItem>
        <CarouselItem className="p-4 border-2">Slide 4</CarouselItem>
        <CarouselItem className="p-4 border-2">Slide 5</CarouselItem>
      </Carousel>

      <div className="flex items-center justify-center gap-4 my-8">
        <button
          onClick={handlePrev}
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
        >
          Prev
        </button>
        <button
          onClick={handleNext}
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
        >
          Next
        </button>
      </div>
    </div>
  );
}

As you can see, using the ref to the carousel component, we are able to access the prev and next functions of the carousel.

Getting Active Slide

To get the active slide, we can create a state variable which will be updated every time a scroll occurs. Basically, we will add an event listener which will update our variable.

const updateActiveSlide = useCallback(() => {
  if (parentContainer.current && itemWidth) {
    const index = Math.round(parentContainer.current.scrollLeft / itemWidth);

    if (onActiveSlideChange) onActiveSlideChange(index);
  }
}, [itemWidth]);

useEffect(() => {
  const container = parentContainer.current;
  if (!container) return;

  container.addEventListener("scroll", updateActiveSlide, {
    passive: true,
  });
  return () => {
    container.removeEventListener("scroll", updateActiveSlide);
  };
}, [updateActiveSlide]);

We can add the above code block in our carousel component. Whenever, a scroll occurs it will call the updateActiveSlide function, to update the active slide. We can create an onActiveSlideChange prop to be able to access our active-slide outside of the main carousel component.

interface CarouselProps {
  children: React.ReactNode;
  slides: number;
  onActiveSlideChange?: (index: number) => void;
}

Here is the usage:

function App() {
  const carouselRef = useRef<CarouselRefType>(null);
  const [active, setActive] = useState(0);

  useEffect(() => {
    console.log(active);
  }, [active]);

  const handlePrev = () => {
    if (!carouselRef.current) return;

    carouselRef.current.prev();
  };

  const handleNext = () => {
    if (!carouselRef.current) return;

    carouselRef.current.next();
  };

  return (
    <div className="m-4">
      <Carousel ref={carouselRef} slides={3} onActiveSlideChange={setActive}>
        <CarouselItem className="p-4 border-2">Slide 1</CarouselItem>
        <CarouselItem className="p-4 border-2">Slide 2</CarouselItem>
        <CarouselItem className="p-4 border-2">Slide 3</CarouselItem>
        <CarouselItem className="p-4 border-2">Slide 4</CarouselItem>
        <CarouselItem className="p-4 border-2">Slide 5</CarouselItem>
      </Carousel>

      <div className="flex items-center justify-center gap-4 my-8">
        <button
          onClick={handlePrev}
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
        >
          Prev
        </button>
        <button
          onClick={handleNext}
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
        >
          Next
        </button>
      </div>
    </div>
  );
}

Wrapping up

Thats it! It was that simple to build a simple carousel component. It is a basic implementation but we can add features like auto-slide, responsive slides per view and orientation and many more. As this blog is getting long, I will be writing about them in my next blog. Till then If you liked reading the blog, consider giving a follow.

Thanks for Reading!!! Happy Learning!!!

0
Subscribe to my newsletter

Read articles from Kalpak Goshikwar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Kalpak Goshikwar
Kalpak Goshikwar