React: Carousel Component
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:
Eyebrow text
Card 1
Mustache text
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 text
Card 2
Mustache text
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 text
Card 3
Mustache text
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.
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 card
s 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 toauto
so that we can scroll - later on we'll be using
offsetLeft
for each of thecard
s 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 thecards
container, not the overall browser window. To achieve this, we needposition: relative;
- We want that
- I've also set
scroll-snap-type: x mandatory;
which opts the unordered list into scroll snapping. This, in conjunction withscroll-snap-align
on the childcard
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 cardsflex: none;
, which is shorthand for:flex-grow: 0;
, which tells the flex item not to grow to fill its flex containerflex-shrink: 0;
, which tells the flex item not to shrink to the minimum size it can take upflex-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> );}