React: Carousel Component

  • React
  • JavaScript
  • CSS

The last couple of weeks I've found myself building the same (or very similar) React carousel component several different times, so it felt like a great opportunity to write a blog post about the component. There are a lot of ways to style it or add variants (some of which I'll talk about below), but I've found there's a pretty consistent structure to all of them, and that will be the focus of this post.

What makes this carousel extra fun is that it digs into exotic CSS properties like scroll-snap-type and scroll-snap-align, or a use of position: relative; that doesn't have to do with absolute positioning. On the JavaScript and React side of the house, we end up using an IntersectionObserver and lots of React refs (including some dynamically generated refs). There's definitely a lot to sink your teeth into, so let's get into it.

What we're building

Here is the component that we'll be building in this blog post:

I've seen several different names for this component, but "carousel" and "slideshow" are probably the most commonly used ones. Basically, there are a few cards laid out side by side horizontally, and they overflow horizontally. The user can swipe to scroll through each of the cards. There are indicator dots below the cards, one indicator dot per card. There are active and inactive styles applied to the indicator dots to indicate which respective card is currently visible. The user can also click on each of the dots to be scrolled to its respective card.

You'll most frequently see this pattern used to consolidate a lot of lengthy contents on mobile devices. I've simulated this above by giving the thing a pretty narrow max-width. Typically once you get to larger screen sizes you can swap to display: grid; to use that increased real estate to showcase all of the contents on the page together.

Aside from the active and inactive indicator dots, the other key user interaction is snappy scrolling; when the user swipes to the next card it sort of "snaps" into place. This and smooth scrolling when a user (who has prefers-reduced-motion set to false) clicks on an indicator dot are things I'll walk through in this blog post.

I also want to call out Adam Argyle's blog post, Nintendo Switch Homescreen recreated with CSS and li'l bit of JS, which he posted a few days ago. It's not exactly what we're building here, but it is way cooler (check out those haptic and sound effects!), and it covers a good deal of the same CSS stuff we cover here. Adam's truly one of the CSS greats, so I defer to him on some of the CSS properties I mention below.

Out of scope for this blog post

There are a lot of variants for this component which I won't be building here, but I think are worth mentioning. Perhaps you might take them as a stretch goal to build on your own after reading this post.

Sometimes there will be left and right arrows that can be clicked on that also scroll the user forward or backward through the carousel.

Another common pattern is an "infinite" scrolling carousel - advancing from the last slide wraps you around to the first slide; advancing backward from the first slide will wrap you around to the last slide. If you decide to build this, be very careful that you aren't building a keyboard trap.

Lastly, you'll frequently find autoplaying variants of this component. When the page loads, the carousel will automatically advance to the next card after a given amount of time, then again to the next card after that. If you decide to build this, please read the W3C Carousels Tutorial article to ensure you're building an accessible experience. One key consideration is that you need to provide the user the option to pause the autoplay.

Scaffolding our component

Enough pretense, let's start building this thing. We'll begin with the basic markup and the CSS styles needed for the scrolling card container, the cards, and the indicator dots.

Markup

Here's the starting point for our component:

"use client";import { useId } from "react";import styles from "./CarouselComponent.module.scss";
export default function CarouselComponent({ id, cards }) {  const uniqueId = useId();  const carouselId = `${uniqueId}${id}`;
  return (    <div className={styles["carousel-component"]} id={carouselId}>      {/* Cards */}      <ul className={styles["cards"]}>        {cards.map((card, index) => {          const { eyebrow, headline, mustache, description } = card;          const id = index + 1;          return (            <li              key={index}              id={`${carouselId}-${id}`}              className={styles["card"]}            >              <p className={styles["eyebrow"]}>{eyebrow}</p>              <h2 className={styles["headline"]}>                {headline}              </h2>              <p className={styles["mustache"]}>{mustache}</p>              <p className={styles["description"]}>{description}</p>            </li>          );        })}      </ul>      {/* Indicator dots */}      <nav aria-label={id}>        <ul className={styles["indicators"]}>          {cards.map((_, index) => {            const id = index + 1;            return (              <li key={index}>                <a                  href={`#${carouselId}-${id}`}                  aria-label={`Navigate to card ${id}`}                  className={`${styles["indicator"]}`}                />              </li>            );          })}        </ul>      </nav>    </div>  );}

We have a container div with the class carousel-component. Inside of that are two child elements: an unordered list with the class cards, and a nav for the indicator dots.

Each list item inside of the cards unordered list has the class card, and will be an individual card. The contents aren't very important here, but I've given them a pretty standard set of text elements: a headline with eyebrow and mustache text followed by a longer description paragraph.

As I'll be using things like useState and useEffect, this needs to be a client component. At the moment I've only imported the useId hook, which I'm using to create a unique ID for each instance of the carousel. This is necessary since we don't know whether there will be multiple instances of this component on a page. The component has an id prop. If a value is passed for that prop, that value gets combined with the uniqueId generated by useId to help identify the overall carousel component as well as the cards within the carousel.

The id prop is also used as the aria-label attribute value on the nav element that contains the indicator dots. This is again useful if there are multiple instances of this component on the page (or other nav elements).

From MDN:

If a page includes more than one navigation landmark, each should have a unique label.

Otherwise it's all pretty standard React iteration stuff. We have a cards prop that we loop over to create each of the cards and each of the indicator dots.

Adding styles

So now let's apply some styles, first to the cards and then to the indicator dots.

Card container

Here are the styles for our cards unordered list:

.cards {  max-width: 100%;  position: relative;  scroll-snap-type: x mandatory;  overflow-y: hidden;  overflow-x: auto;  list-style-type: none;  display: flex;  flex-direction: row;  gap: 24px;  padding-bottom: 24px;}

The key notes here are:

  • it's a horizontal flex container with overflow-x set to auto so that we can scroll
  • later on we'll be using offsetLeft for each of the cards to determine how far to scroll this container when an indicator is clicked
    • We want that offsetLeft value to be the distance between the card and the left edge of the cards container, not the overall browser window. To achieve this, we need position: relative;
  • I've also set scroll-snap-type: x mandatory; which opts the unordered list into scroll snapping. This, in conjunction with scroll-snap-align on the child card elements, helps us achieve snappy swiping interactions

Individual cards

Let's take a look at the styles for the individual card list items:

.card {  display: block;  scroll-snap-align: center;  max-width: 180px;  background-color: white;  border: 1px solid black;  border-radius: 16px;  padding: 32px 16px;  flex: none;  .eyebrow,  .mustache {    margin-bottom: 8px;    font-weight: bold;    font-size: 8px;    line-height: 12px;  }  .headline {    font-size: 1.2rem;    line-height: 1.263;    margin-bottom: 8px;  }  .description {    font-size: 12px;  }}

Aside from some basic typography, background color, and border styles, the key styles here are:

  • scroll-snap-align: center;, which helps define the scroll snap behavior for the cards
  • flex: none;, which is shorthand for:
    • flex-grow: 0;, which tells the flex item not to grow to fill its flex container
    • flex-shrink: 0;, which tells the flex item not to shrink to the minimum size it can take up
    • flex-basis: auto;, which tells the flex item that its base size should be set according to the width and height of its content
  • max-width: 180px;, which gives the maximum width that the list item should take up. This is admittedly a little bit magic-numbery, but that's kind of necessary when it comes to achieving the desired layout

Indicator dots

Now let's style the indicators:

.indicators {  max-width: 100%;  list-style-type: none;  display: flex;  flex-direction: row;  flex-wrap: wrap;  gap: 24px;  align-items: center;  justify-content: center;  margin-left: 0;  margin-bottom: 0;}

Not much of interest here, just more flexbox stuff.

And here are the styles for the specific indicator dots:

.indicator {  height: 12px;  width: 12px;  display: block;  border-radius: 50%;  border-width: 1px;  border-style: solid;  text-decoration: none;}
.indicator--active {  background-color: black;  border-color: black;}
.indicator--inactive {  background-color: gray;  border-color: gray;}

In lieu of using SVGs for the indicator dot icons, I'm creating my own indicator circles with a defined height/width/border radius combo. I've also created some --active and --inactive modifier classes that will get used later on.

With all of this set up, the thing kind of works as-is out of the box. We're obviously missing a mechanism to trigger active and inactive state, but for the most part we're achieving our desired layout, horizontal scrolling, and swiping interactions purely through CSS. This is the approach I like to take for complicated components like this: use as many native browser/CSS features as possible, and then sprinkle in the JavaScript/React code on top to finesse the interactions.

Usage

Before proceeding, it's worth dropping this into a React app page so we can start using it. We would this like so:

<CarouselComponent  id="cards"  cards={[    {      eyebrow: "Eyebrow text",      headline: "Card 1",      mustache: "Mustache text",      description:        "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud.",    },    {      eyebrow: "Eyebrow text",      headline: "Card 2",      mustache: "Mustache text",      description:        "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud.",    },    {      eyebrow: "Eyebrow text",      headline: "Card 3",      mustache: "Mustache text",      description:        "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud.",    },  ]}/>

Again, the contents of the cards can be just about anything - images, different text layouts, links, whatever.

Adding usePrefersReducedMotion custom hook

One last bit of setup here. When the user clicks on an indicator, I want the container to do smooth scrolling by default. However, we need to respect a user's preference for reduced motion, as fast movements from animation or smooth scrolling can make users sick. Check out Heather Migliorisi's Smooth Scrolling and Accessibility article on CSS Tricks for more info.

Fortunately, the prefers-reduced-motion media query offers us a way to hook into whether a user has this preference set to true (aka "yes, please reduce motion") or false (aka "nope, animations and smooth scrolling are cool with me"). Even more fortunately, Joshua Comeau has shared a custom React hook called usePrefersReducedMotion that makes it very easy to use that media query in React and Next.js See his snippet here for this..

We should update our CarouselComponent to use this hook. Within Next.js we import and use this custom hook like so:

"use client";import { useId } from "react";import usePrefersReducedMotion from "../../../hooks/use-prefers-reduced-motion";import styles from "./CarouselComponent.module.scss";
export default function CarouselComponent({ id, cards }) {  const uniqueId = useId();  const carouselId = `${uniqueId}${id}`;
  const prefersReducedMotion = usePrefersReducedMotion();
  return (    <div className={styles["carousel-component"]} id={carouselId}>      {/* ...everything else unchanged */}

Adding refs

From here we need to start setting up an IntersectionObserver. We want the IntersectionObserver to observe the ul with the cards class, and we want it to observe which of our card list items is currently in view. We'll do all of this using React refs. We can easily create the container ref with something like const observerRef = useRef(null);, but dynamically making a ref for each card is a little more complicated.

First, we pass useRef() an empty array for its initialValue:

const cardRefs = useRef([]);

Next, we can use a callback ref within our .map(). A callback ref function gets called after the component has rendered, and that function gets the rendered DOM node passed as an argument. We can use that node within that function to dynamically update the cardRefs.current array:

ref={(node) => {  if (node) {    // Store the ref in the cardRefs.current array at the corresponding index.    cardRefs.current[index] = { current: node};  }}}

With the above changes, here's what our component looks like at the moment:

"use client";import { useId, useRef } from "react";import usePrefersReducedMotion from "../../../hooks/use-prefers-reduced-motion";import styles from "./CarouselComponent.module.scss";
export default function CarouselComponent({ id, cards }) {  const uniqueId = useId();  const carouselId = `${uniqueId}${id}`;
  const prefersReducedMotion = usePrefersReducedMotion();
  const observerRef = useRef(null);  const cardRefs = useRef([]);
  return (    <div className={styles["carousel-component"]} id={carouselId}>      {/* Cards */}      <ul className={styles["cards"]} ref={observerRef}>        {cards.map((card, index) => {          const { eyebrow, headline, mustache, description } = card;          const id = index + 1;          return (            <li              key={index}              id={`${carouselId}-${id}`}              className={styles["card"]}              ref={(node) => {                if (node) {                  cardRefs.current[index] = { current: node };                }              }}            >              <p className={styles["eyebrow"]}>{eyebrow}</p>              {/* ...everything else unchanged */}

Adding an IntersectionObserver and activeIndex state

With our refs in place, we can add in our IntersectionObserver. This is a browser API that lets us see whether elements are currently within view of a defined element (or the overall browser window). Because we're synchronizing React DOM nodes with a browser API, we need to drop our IntersectionObserver into a useEffect:

useEffect(() => {  const observer = new IntersectionObserver(    (entries) => {      entries.forEach((entry) => {        if (entry.isIntersecting) {          // Do some work here        }      });    },    {      root: observerRef.current,      rootMargin: "24px",      threshold: 1.0,    }  );  cardRefs.current.forEach((cardRef) => observer.observe(cardRef.current));  return () => {    observer.disconnect();  };}, []);

This effect runs on component mount, spins up a new IntersectionObserver, observing each of the refs we dynamically generated above in the cardRefs.current array. We also define the root element for the IntersectionObserver as the observerRef.current ref (which is the ul that contains the cards). When the component unmounts, we disconnect the observer.

For each of the card refs, if the card is visible within the observerRef ref, then the following condition passes:

if (entry.isIntersecting) {  // Do some work here}

What we're aiming for is to derive state from what's currently intersecting the observer; so we need to go ahead and add an activeIndex state variable:

const [activeIndex, setActiveIndex] = useState(0);

When a card intersects, we want to update that state variable with the index. As I have it set up, the first card has an id of cards-1, the second card has an id of cards-2, and the third card has an id of cards-3. We need to extract the last number from that id and subtract 1 to get the index value. Here is some regex that does exactly that:

if (entry.isIntersecting) {  setActiveIndex(Number(entry.target.id.match(/\d+$/)?.[0]) - 1);}

Finally, with the activeIndex state variable available to us, we want to update the indicator dot classlist to use the --active and --inactive modifier classes we wrote earlier:

<li key={index}>  <a    href={`#${carouselId}-${id}`}    aria-label={`Navigate to card ${id}`}    className={`${styles["indicator"]} ${      activeIndex === index        ? `${styles["indicator--active"]}`        : `${styles["indicator--inactive"]}`    }`}  /></li>

Here's the current state of our component with all of the above changes:

"use client";import { useId, useRef, useEffect, useState } from "react";import usePrefersReducedMotion from "../../../hooks/use-prefers-reduced-motion";import styles from "./CarouselComponent.module.scss";
export default function CarouselComponent({ id, cards }) {  const uniqueId = useId();  const carouselId = `${uniqueId}${id}`;
  const prefersReducedMotion = usePrefersReducedMotion();
  const observerRef = useRef(null);  const cardRefs = useRef([]);
  const [activeIndex, setActiveIndex] = useState(0);
  useEffect(() => {    const observer = new IntersectionObserver(      (entries) => {        entries.forEach((entry) => {          if (entry.isIntersecting) {            setActiveIndex(Number(entry.target.id.match(/\d+$/)?.[0]) - 1);          }        });      },      {        root: observerRef.current,        rootMargin: "24px",        threshold: 1.0,      }    );    cardRefs.current.forEach((cardRef) => observer.observe(cardRef.current));    return () => {      observer.disconnect();    };  }, []);
  return (    <div className={styles["carousel-component"]} id={carouselId}>      {/* Cards */}      <ul className={styles["cards"]} ref={observerRef}>        {/* ...Cards remains unchanged */}      </ul>      {/* Indicator dots */}      <nav aria-label={id}>        <ul className={styles["indicators"]}>          {cards.map((_, index) => {            const id = index + 1;            return (              <li key={index}>                <a                  href={`#${carouselId}-${id}`}                  aria-label={`Navigate to card ${id}`}                  className={`${styles["indicator"]} ${                    activeIndex === index                      ? `${styles["indicator--active"]}`                      : `${styles["indicator--inactive"]}`                  }`}                />              </li>            );          })}        </ul>      </nav>    </div>  );}

With these updates we have something that is almost complete! If we scroll/swipe between the cards, the activeIndex is getting derived from the IntersectionObserver and the indicator dots are getting updated. That's really cool.

Handling indicator clicks

The last thing we need to do is finesse what happens when the user clicks on an indicator dot. As it stands now, it mostly works to navigate the user to the correct card, but we can offer the smooth scrolling experience for those users who have prefers-reduced-motion set to false.

To do so I'll create the following click handler function:

function handleIndicatorClick(event) {  event.preventDefault();  const index = Number(event.target.hash.match(/\d+$/)?.[0]) - 1;  const item = cardRefs.current[index].current;  const container = observerRef.current;  if (item) {    if (container) {      container.scrollTo({        left: item.offsetLeft,        behavior: prefersReducedMotion ? "instant" : "smooth",      });    }  }}

Similar to above, we need to get the trailing number out of the link's href attribute and subtract 1 to get the proper index. We then use that index to get the correct ref from the cardRefs.current array, then scroll to it within the container.

We use scrollTo(), offering it the following object:

{  left: item.offsetLeft,  behavior: prefersReducedMotion ? "instant" : "smooth",}

Since the container is set to position: relative;, the offsetLeft of the ref we're scrolling to is the distance between the left edge of that ref and the left edge of the container.

Lastly, we can leverage the value of prefersReducedMotion to determine whether the scroll behavior is instant or smooth.

You might be wondering, "Why don't we call setActiveIndex(index) within the click handler? If I click on the second indicator dot, doesn't it stand to reason that I should update activeIndex to 1?"

This was my line of thinking the first time I built this component, but it wound up being buggy. With a setActiveIndex in the IntersectionObserver and another setActiveIndex in the click handler, I would get competing active indices, especially if I would click from the first slide to the third slide or vice versa. It was a moment of revelation and it greatly simplified things by only having the IntersectionObserver be the place where state is derived, and for the click handler to only handle the clicking and scrolling behavior.

With all of these changes, our component is finished:

"use client";import { useId, useRef, useEffect, useState } from "react";import usePrefersReducedMotion from "../../../hooks/use-prefers-reduced-motion";import styles from "./CarouselComponent.module.scss";
export default function CarouselComponent({ id, cards }) {  const uniqueId = useId();  const carouselId = `${uniqueId}${id}`;
  const prefersReducedMotion = usePrefersReducedMotion();
  const observerRef = useRef(null);  const cardRefs = useRef([]);
  const [activeIndex, setActiveIndex] = useState(0);
  useEffect(() => {    const observer = new IntersectionObserver(      (entries) => {        entries.forEach((entry) => {          if (entry.isIntersecting) {            setActiveIndex(Number(entry.target.id.match(/\d+$/)?.[0]) - 1);          }        });      },      {        root: observerRef.current,        rootMargin: "24px",        threshold: 1.0,      }    );    cardRefs.current.forEach((cardRef) => observer.observe(cardRef.current));    return () => {      observer.disconnect();    };  }, []);
  function handleIndicatorClick(event) {    event.preventDefault();    const index = Number(event.target.id.match(/\d+$/)?.[0]) - 1;    const item = cardRefs.current[index].current;    const container = observerRef.current;    if (item) {      if (container) {        container.scrollTo({          left: item.offsetLeft,          behavior: prefersReducedMotion ? "instant" : "smooth",        });      }    }  }
  return (    <div className={styles["carousel-component"]} id={carouselId}>      {/* Cards */}      <ul className={styles["cards"]} ref={observerRef}>        {cards.map((card, index) => {          const { eyebrow, headline, mustache, description } = card;          const id = index + 1;          return (            <li              key={index}              id={`${carouselId}-${id}`}              className={styles["card"]}              ref={cardRefs.current[index]}            >              <p className={styles["eyebrow"]}>{eyebrow}</p>              <h2 className={styles["headline"]}>                {headline}              </h2>              <p className={styles["mustache"]}>{mustache}</p>              <p className={styles["description"]}>{description}</p>            </li>          );        })}      </ul>      {/* Indicator dots */}      <nav aria-label={id}>        <ul className={styles["indicators"]}>          {cards.map((_, index) => {            const id = index + 1;            return (              <li key={index}>                <a                  href={`#${carouselId}-${id}`}                  aria-label={`Navigate to step ${id}`}                  className={`${styles["indicator"]} ${                    activeIndex === index                      ? `${styles["indicator--active"]}`                      : `${styles["indicator--inactive"]}`                  }`}                  onClick={handleIndicatorClick}                />              </li>            );          })}        </ul>      </nav>    </div>  );}