Carousel using React Hooks and CSS scroll snap

Carousel using React Hooks and CSS scroll snap

We all know how difficult it is to build an accessible, user friendly and mantainable carousel, specially from scratch. Well, for a project I was working on, I needed to build one. The carousel specs also included a scroll indicator, arrow buttons to go back and forward trought for desktop devices, but support touch scroll support in mobile, and finally, it needed to be performant and accessible.

At this point, a great option would have been to use one of the many great react-carousel libraries out there and that could have been it. End of this entry. But I also took this as a great opportunity to try and build one from scratch and fully understand how to develop it using only modern css and React. I mean, If that hadn't gone well, I could always go back and use a library, right?

But let's fully dive in and start the coding. We are gonna try make this component as reusable as possible. So let's take a look at our base structure first:

const Carousel = ({ children, count }) => {
  return (
    <div className="container">
      <div className="carousel">{children}</div>
    </div>
  )
}

CSS Scroll Snap

I told you we will use modern CSS. This CSS property provide us a way to get more control over the scroll experience by setting different snap positions.

scroll-snap-type

We use this property in our container to specify the direction and the behavior of the scroll.

CSS Scroll Snap on developer.mozilla.org

.carousel {
  scroll-snap-type: y mandatory;
}

scroll-snap-align

This is for all cards and is used to define the alignment which could be set as center, start, and end. For this example, we are gonna use start.

To keep the ability of allowing all types of childs outside the component, we are gonna use the > * selector to style only all the direct children of .container

.carousel > * {
  scroll-snap-align: start;
}

Let's add some other key styles to the carousel.

.carousel {
  display: flex;
  align-items: center;
  width: 100%;
  overflow-x: scroll;
  scroll-snap-type: x mandatory;
}
 
/* Hide scroll bar */
.carousel::-webkit-scrollbar {
  display: none;
}
 
/* All global directed childs */
.carousel > * {
  min-width: 100%;
  margin-right: 0;
  scroll-snap-align: start;
}

Scroll indicator

We'll work on a seperate component and use useEffect and a scroll event listener. Also, useRef will be needed to prevent a re-render of our whole component each time the scroll listener is triggered.

const Carousel = ({ children, count }) => {
  const carouselRef = useRef(null)
 
  return (
    <div className="container">
      <div className="carousel" ref={carouselRef}>
        {children}
      </div>
 
      <ScrollIndicator ref={carouselRef} count={count} />
    </div>
  )
}

As we are passing a ref from the parent component, we need to use forwardRef in the ScrollIndicator component.

const ScrollIndicator = forwardRef(({ count }, carouselRef) => {
  return (
    <div className="dots-container">
      {[...Array(count).keys()].map((i) => (
        <div key={i} className="dot" />
      ))}
    </div>
  )
})

[...Array(count).keys()] is used to create an array based on the count number but also setting the keys as values. e.g. count = 2 array = [0, 1]

.dots-container {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 1rem;
}
 
.dot {
  width: 0.5rem;
  height: 0.5rem;
  margin: 0 0.5rem;
  background: black;
  border-radius: 50%;
  opacity: 0.25;
}

Inside the ScrollIndicator component we need to add a local state for the scroll progress and some way to update it each time the user scrolls on the carousel. For this we are gonna use useState, useEffect and the sliderRef.

const ScrollIndicator = forwardRef(({ count }, carouselRef) => {
  const [scrollProgress, setScrollProgress] = useState(0)
 
  useEffect(() => {
    if (!carouselRef.current) return
 
    const element = carouselRef.current
 
    const scrollListener = () => {
      const windowScroll = element.scrollLeft
      const totalWidth = element.scrollWidth - element.clientWidth
 
      if (windowScroll === 0) setScrollProgress(0)
      if (windowScroll > totalWidth) setScrollProgress(100)
      return setScrollProgress((windowScroll / totalWidth) * 100)
    }
 
    element.addEventListener('scroll', scrollListener, { passive: true })
    return () => element.removeEventListener('scroll', scrollListener)
  }, [])
 
  const activeDot = Math.floor((scrollProgress * count) / 110)
 
  return {
    /* ... */
  }
})

To break it down,

  • First, the carouselRef is used to read the element properties and calculate the state of the scroll.
  • element.scrollLeft is the position of the scrollbar from the leftmost point of the container.
  • element.scrollWidth - element.clientWidth is the maximum position for the scrollbar to scroll based on the total width of the container.
  • (windowScroll / totalWidth) * 100) is the percentage scrolled by the user.
  • We are attaching this whole function to the scroll event at the end of the useEffect
  • And finally, we are using the const activeDot = Math.floor((scrollProgress * count) / 110) to calculate the active dot based on the scrollProgress and the total number of elements.

Now, we can add an active class and bind it to const activeDot.

const ScrollIndicator = forwardRef(({ count }, carouselRef) => {
  return (
    <div className="dots-container">
      {[...Array(count).keys()].map((i) => (
        <div key={i} className={`dot ${activeDot === i ? 'active-dot' : ''}`} />
      ))}
    </div>
  )
})
.active-dot {
  opacity: 1;
}

Scroll to buttons

This part is a bit Tricky and unfortunately does not have the greatest browser Support Yet, but nothing that a polyfill can't solve.

Inside our parent element, we need to add two buttons and the onClick function for each of them.

const Carousel = ({ children, count }) => {
  const carouselRef = useRef(null)
 
  const scrollToRight = (e) => {
    e.preventDefault()
    carouselRef.current.scrollBy({
      left: carouselRef.current.clientWidth,
      top: 0,
      behavior: 'smooth',
    })
  }
 
  const scrollToLeft = (e) => {
    e.preventDefault()
    carouselRef.current.scrollBy({
      left: -carouselRef.current.clientWidth,
      top: 0,
      behavior: 'smooth',
    })
  }
 
  return (
    <div className="container">
      <div>
        <button onClick={scrollToLeft}>Prev</button>
        <button onClick={scrollToRight}>Next</button>
      </div>
 
      {/* ... */}
    </div>
  )
}

We are using scrollBy() to scroll inside our element and carouselRef.current.clientWidth to read the width of the screen.

Caveats

At the point I'm writing this post scrollBy and smooth scroll property aren't highly supported by all the browser but you can use some polyfill to easily solve this problem.

Usage

<Carousel count={items.length}>
  {items.map((item) => (
    <div key={item.id}>
      <img
        className="card-image"
        src={item.image.url}
        alt={item.image.alt}
        width={item.image.width}
        height={item.image.height}
      />
    </div>
  ))}
</Carousel>
OlderInfinite marquee animation with React and CSSNewerBuilding PlanetScale's new homepage