How to Create Your Own React Carousel

Finding a pre-made carousel component that fits well with your application can be a challenge. Even if you can customize the look and feel, an old jQuery-based component will stick out like a sore thumb in your React application.

Implementing your own carousel is not hard, and it can save you from adding more dependencies to your application. There are some relatively recent browser features that can help as well, as long as you don’t have to support old browsers (sorry, Internet Explorer).

Implementation

Some carousels just rotate, one at a time, through a list of images or other content. Those carousels are boring.

For this example, I’ll be implementing a carousel that presents groups of cards. I’ll refer to each group as a horse (this is a carousel, after all). The only dependency I’ve used is lodash.

There are two main challenges for carousels in general:

  • Snapping scrolling to each horse (group of cards).
  • Determining which horse is currently front-and-center.

For this carousel specifically, we also need to ensure that the cards are all the same size and that each group fills the width of the horse.

Let’s get started:

export const Carousel: React.FunctionComponent<Props> = ({ cards }) => {
  const carouselRef = useRef<HTMLUListElement>(null);
  const horseRefs = useRef<(HTMLLIElement | null)[]>([]);
  const [currentHorseIndex, setHorseIndex] = useState(0);
  const [carouselWidth, setCarouselWidth] = useState<number | undefined>(undefined);

  const targetCardWidth = 360; // pixels
  const cardsPerHorse = Math.max(
    1,
    Math.floor((carouselWidth ?? 0) / targetCardWidth)
  );
  const horses = _.chunk(cards, cardsPerHorse);

  useEffect(() => {
    const updateCarouselWidth = () => {
      setCarouselWidth(carouselRef.current?.offsetWidth ?? 0);
    };
    const onResize = _.debounce(() => {
      updateCarouselWidth();
    }, 300);

    window.addEventListener("resize", onResize);
    updateCarouselWidth();

    return () => {
      window.removeEventListener("resize", onResize);
    };
  }, []);

  return (
    <ul className={"carousel"} ref={carouselRef}>
      {horses.map((horse, index) => (
        <li
          key={horse.map(card => card.id).join(",")}
          ref={li => (horseRefs.current[chunkIndex] = li)}
          className={"horse"}
        >
          {horse.map(card => (
            <div
              key={card.id}
              className={"card"}
              card={card}
              />
          ))}
          {_.range(cardsPerHorse - horse.length).map(i => (
            <div key={i} className={"card"} />
          ))}
        </li>
      ))}
    </ul>
  );
};
$spacing: 16px;

.carousel {
  display: flex;
  flex-direction: row;

  padding: $spacing 0;
  margin: $spacing ($spacing * -1);

  overflow-x: auto;
}

.horse {
  flex: 0 0 100%;

  display: flex;
  padding: 0 ($spacing / 2);
  box-sizing: border-box;

  list-style-type: none;
}

.card {
  flex: 1 1 0;
  margin: 0 ($spacing / 2);
  border-radius: 8px;
  box-shadow: 0 0 4px #333;
  background: white;
  min-height: 160px;
}

.card-spacer {
  flex: 1 1 0;
}

The basic structure is easy to set up using flexbox. This allows us to distribute all of the available space among the cards on the current horse. In order to figure out how many cards to display per horse, we need a target width. And because the width of the carousel might change (when the browser is resized), we need to register a listener for that. Finally, we need to insert blanks at the end in case the total number of cards is not evenly divisible by cards-per-horse; that way we won’t end up with some extra wide cards at the end.

You might also notice the negative margins on the left and right. Depending on where the carousel appears in your layout, these may not be necessary. But if the carousel is indented within its container, this will ensure that content appears correctly when scrolling. Without it, the cards would appear to be clipped by some whitespace.

Scroll Snapping

No self-respecting carousel would allow scrolling to stop at just any position. Instead, it should snap to a content boundary. There is CSS support for this behavior, but it’s still pretty new.

What we want to do in this case is snap to the nearest horse. We can do this with the following modifications to the CSS:

.carousel {
  scroll-snap-type: x mandatory;
  overscroll-behavior: contain;
}

.horse {
  scroll-snap-align: start;
}

There are three important properties here:

  • scroll-snap-type is set to the x-axis. The mandatory means that it will snap no matter where scrolling stops (the alternative would be to only snap if it is close).
  • scroll-snap-align is set to start, although (in our case) it doesn’t really matter if this is start, center, or end. This property controls how elements are anchored within the container, but since our elements are the same size as the container, it makes no difference.
  • The overscroll-behavior is added for the benefit of mobile devices. Since many browsers will interpret a left or right swipe as a back or forward navigation, and the carousel is also scrolling horizontally, we this should prevent any accidental navigation.

Intersection Observer

The final challenge is detecting which horse is the current one. This could be used to render an indicator (usually a series of dots below the carousel) or some other logic.

One thing that you could do is register a listener for the onScroll event (similar to the listener for onResize above). But this event is fired repeatedly as the user scrolls, and handling every event noticeably slows the browser. You could debounce the event, waiting a minimum time between events, but this causes a different kind of noticeable slowness.

A newer browser API called IntersectionObserver solves this problem by having the browser do the work for you. It will watch for two elements to intersect, then invoke a callback as soon as they do, freeing up the main thread to do other things.

  useEffect(() => {
    const callback: IntersectionObserverCallback = entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const index = (horseRefs.current as Element[]).indexOf(entry.target);
          setHorseIndex(index);
        }
      });
    };

    const observer = new IntersectionObserver(callback, {
      root: carouselRef.current,
      threshold: 0.6
    });
    horseRefs.current.filter(h => h !== undefined).forEach(horse => {
      observer.observe(horse);
    });

    return function cleanup() {
      observer.disconnect();
    };
  }, [horses, cardsPerHorse]);

I will admit, this appears a bit inscrutable at first. Most of the complexity comes from having to register the observer for each horse. And when the callback fires, we have to figure out which horse caused it. The intersection threshold is set to 0.6, which means that the callback will fire whenever a horse occupies 60% or more of the carousel.

All that’s left is to hook up the currentHorseIndex to an indicator, and you’ll have a fully-functioning carousel, built from scratch!