Build a React Carousel Slider: Scroll‑Snap and Custom Hook Approaches

Build an accessible, performant React carousel with scroll-snap and transform approaches, including drag, autoplay, and best practices.

ASOasis
8 min read
Build a React Carousel Slider: Scroll‑Snap and Custom Hook Approaches

Image used for representation purposes only.

Overview

A carousel slider is a common UI pattern for showcasing images, cards, or testimonials inside a horizontally scrollable area with previous/next controls, touch dragging, and optional autoplay. In React, you can implement a carousel in multiple ways. This article walks through two production-quality approaches—CSS scroll-snap (lightweight, accessible) and a transform-based slider (fine-grained control, drag, and autoplay)—with attention to accessibility, performance, and maintainability.

Choose your approach

Before coding, decide what you need:

  • Content type: simple images vs. complex card layouts.
  • Input methods: mouse, touch, keyboard.
  • Looping: finite or infinite.
  • Autoplay: on by default, pause on hover, respect reduced-motion.
  • Accessibility: readable by screen readers, operable with a keyboard, sensible focus order.
  • Performance: responsive resizing, lazy loading, virtualization for large sets.

Two solid implementation strategies:

  1. Scroll-snap carousel: uses native scrolling + CSS snap points. Less JS, highly accessible, great for most use cases.
  2. Transform-based carousel: uses translateX for precise animation, optional drag physics and loop mode.

This approach relies on native horizontal scrolling with CSS scroll-snap. It’s fast, minimal, and accessibility-friendly by default.

Markup and styles

// CarouselSnap.jsx
import { useRef } from 'react';

export function CarouselSnap({ items, ariaLabel = 'Carousel' }) {
  const viewportRef = useRef(null);

  const scrollByOne = (dir) => {
    const el = viewportRef.current;
    if (!el) return;
    const width = el.clientWidth;
    el.scrollBy({ left: dir * width, behavior: 'smooth' });
  };

  return (
    <section aria-roledescription="carousel" aria-label={ariaLabel}>
      <div className="controls">
        <button
          type="button"
          aria-label="Previous slide"
          onClick={() => scrollByOne(-1)}
        ></button>
        <button
          type="button"
          aria-label="Next slide"
          onClick={() => scrollByOne(1)}
        ></button>
      </div>

      <div
        className="viewport"
        ref={viewportRef}
        tabIndex={0}
        role="group"
        aria-label="Slides"
      >
        <ul className="track">
          {items.map((node, i) => (
            <li className="slide" key={i} aria-roledescription="slide" aria-label={`${i + 1} of ${items.length}`}>
              {node}
            </li>
          ))}
        </ul>
      </div>
    </section>
  );
}
/* carousel-snap.css */
.viewport {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  scroll-behavior: smooth; /* optional */
}
.track {
  display: flex;
  gap: 1rem; /* optional spacing */
}
.slide {
  flex: 0 0 100%; /* one slide per viewport */
  scroll-snap-align: center; /* snap each slide to center */
}
.controls { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }

Why it works

  • Accessibility: native scroll is keyboard- and screen-reader-friendly. The focus can remain on interactive content inside slides.
  • Performance: offloads physics and scrolling to the browser.
  • Simplicity: a handful of lines.

Limitations

  • Infinite looping is non-trivial.
  • Programmatic indicators (active dot) need IntersectionObserver or scroll position math.
  • Highly custom drag behavior is limited.

If these are dealbreakers, use the transform-based approach below.

This version gives you explicit control over the slide position, integrates touch/mouse dragging, and supports autoplay. We’ll keep the core small and extensible.

Data model

  • index: current slide index.
  • slideW: computed width of a single slide.
  • drag: pointer state (startX, deltaX, isDragging).
  • options: loop, autoplay, interval, threshold.

The hook

// useCarousel.js
import { useEffect, useRef, useState } from 'react';

export function useCarousel({ length, loop = false, autoplay = false, interval = 5000, threshold = 0.18 }) {
  const [index, setIndex] = useState(0);
  const [slideW, setSlideW] = useState(0);
  const [dragX, setDragX] = useState(0); // current drag offset in px
  const [isDragging, setIsDragging] = useState(false);

  const viewportRef = useRef(null);
  const trackRef = useRef(null);
  const rafRef = useRef(null);
  const autoRef = useRef(null);

  // Measure slide width on resize
  useEffect(() => {
    const el = viewportRef.current;
    if (!el) return;
    const ro = new ResizeObserver(() => setSlideW(el.clientWidth));
    ro.observe(el);
    setSlideW(el.clientWidth);
    return () => ro.disconnect();
  }, []);

  // Autoplay (respect reduced motion and page visibility)
  useEffect(() => {
    if (!autoplay || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const tick = () => setIndex((i) => (loop ? (i + 1) % length : Math.min(i + 1, length - 1)));
    const start = () => { stop(); autoRef.current = setInterval(tick, interval); };
    const stop = () => { if (autoRef.current) clearInterval(autoRef.current); };

    const onVisibility = () => (document.hidden ? stop() : start());
    start();
    document.addEventListener('visibilitychange', onVisibility);
    return () => { stop(); document.removeEventListener('visibilitychange', onVisibility); };
  }, [autoplay, interval, length, loop]);

  // Pointer interactions
  useEffect(() => {
    const el = viewportRef.current;
    if (!el) return;

    let startX = 0;

    const onDown = (e) => {
      const x = 'touches' in e ? e.touches[0].clientX : e.clientX;
      startX = x;
      setIsDragging(true);
      setDragX(0);
    };

    const onMove = (e) => {
      if (!isDragging) return;
      const x = 'touches' in e ? e.touches[0].clientX : e.clientX;
      setDragX(x - startX);
    };

    const onUp = () => {
      if (!isDragging) return;
      const ratio = Math.abs(dragX) / (slideW || 1);
      if (ratio > threshold) {
        const dir = dragX > 0 ? -1 : 1;
        setIndex((i) => {
          const next = i + dir;
          if (loop) return (next + length) % length;
          return Math.max(0, Math.min(length - 1, next));
        });
      }
      setIsDragging(false);
      setDragX(0);
    };

    el.addEventListener('pointerdown', onDown);
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
    el.addEventListener('touchstart', onDown, { passive: true });
    window.addEventListener('touchmove', onMove, { passive: true });
    window.addEventListener('touchend', onUp);

    return () => {
      el.removeEventListener('pointerdown', onDown);
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
      el.removeEventListener('touchstart', onDown);
      window.removeEventListener('touchmove', onMove);
      window.removeEventListener('touchend', onUp);
    };
  }, [isDragging, dragX, slideW, length, loop, threshold]);

  const slideTo = (n) => setIndex(() => (loop ? ((n % length) + length) % length : Math.max(0, Math.min(length - 1, n))));
  const next = () => slideTo(index + 1);
  const prev = () => slideTo(index - 1);

  const offset = -index * slideW + (isDragging ? dragX : 0);

  return { index, slideTo, next, prev, viewportRef, trackRef, offset, isDragging };
}

Component integration

// CarouselX.jsx
import { useCarousel } from './useCarousel';
import './carousel-x.css';

export function CarouselX({ items, loop = false, autoplay = false, interval = 5000, ariaLabel = 'Carousel' }) {
  const { index, slideTo, next, prev, viewportRef, trackRef, offset, isDragging } = useCarousel({
    length: items.length,
    loop,
    autoplay,
    interval,
  });

  return (
    <section aria-roledescription="carousel" aria-label={ariaLabel}>
      <div className="cx-controls">
        <button type="button" aria-label="Previous slide" onClick={prev} disabled={!loop && index === 0}></button>
        <button type="button" aria-label="Next slide" onClick={next} disabled={!loop && index === items.length - 1}></button>
      </div>

      <div className="cx-viewport" ref={viewportRef} role="group" aria-label="Slides" tabIndex={0}>
        <ul
          className={`cx-track ${isDragging ? 'dragging' : ''}`}
          ref={trackRef}
          style={{ transform: `translate3d(${offset}px, 0, 0)` }}
          aria-live="polite"
        >
          {items.map((node, i) => (
            <li className="cx-slide" key={i} aria-roledescription="slide" aria-label={`${i + 1} of ${items.length}`}> 
              {node}
            </li>
          ))}
        </ul>
      </div>

      <div className="cx-dots" role="tablist" aria-label="Slide navigation">
        {items.map((_, i) => (
          <button
            key={i}
            role="tab"
            aria-selected={index === i}
            aria-controls={`slide-${i}`}
            onClick={() => slideTo(i)}
          />
        ))}
      </div>
    </section>
  );
}
/* carousel-x.css */
.cx-viewport { overflow: hidden; position: relative; }
.cx-track { display: flex; will-change: transform; transition: transform 280ms ease; }
.cx-track.dragging { transition: none; cursor: grabbing; }
.cx-slide { flex: 0 0 100%; }
.cx-controls { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }
.cx-dots { display: flex; gap: 0.5rem; justify-content: center; margin-top: 0.5rem; }
.cx-dots > button { width: 8px; height: 8px; border-radius: 50%; background: #ccc; border: 0; }
.cx-dots > button[aria-selected="true"] { background: #333; }

Notes and enhancements

  • Keyboard: add ArrowLeft/ArrowRight handlers on the viewport to call prev/next.
  • Focus: do not trap focus in the carousel. Let users tab into and out of slide content naturally.
  • Looping with clones: for seamless infinite scroll, prepend the last slide and append the first. On transition end, jump the index to the real slide without animation. This avoids a visible boundary.
// Pseudocode: clone technique
// render [last] [0] [1] ... [n-1] [0]
// start at visual index 1 (real slide 0). when moving forward from last real to clone,
// onTransitionEnd jump to real first without transition.

Accessibility checklist

  • Role and names: use aria-roledescription=“carousel” on the section and aria-label with a concise name.
  • Live regions: set aria-live=“polite” on the track so screen readers hear slide changes triggered by buttons.
  • Buttons: ensure buttons have clear aria-labels (Next/Previous) and are focusable.
  • Tabs/dots: treat dots as a tablist with role=“tablist” and each dot as role=“tab” with aria-selected.
  • Reduced motion: respect prefers-reduced-motion by disabling autoplay and using shorter transitions.
  • Color contrast: ensure visible controls and focus rings.

Performance techniques

  • Image lazy loading: use loading=“lazy” and width/height attributes to avoid layout shifts.
  • Responsive images: provide srcset and sizes for better bandwidth usage.
  • Virtualization for very long carousels: render only nearby slides. A simple windowing strategy keeps the DOM light.
  • Hardware acceleration: translate3d ensures GPU compositing for smooth transforms.
  • ResizeObserver: recompute slide width on container resize to keep math accurate.

Example lazy image slide:

function Slide({ src, alt }) {
  return (
    <picture>
      <source srcSet={`${src}?w=800 800w, ${src}?w=1200 1200w`} sizes="(min-width: 900px) 800px, 100vw" />
      <img src={`${src}?w=800`} alt={alt} loading="lazy" width="800" height="500" />
    </picture>
  );
}

Handling edge cases

  • Resize during drag: cancel dragging on resize to avoid incorrect thresholds.
  • RTL layouts: invert next/prev semantics or flip transform direction when dir=“rtl”.
  • Variable-width slides: either normalize widths with CSS or compute each slide’s width and accumulate offsets.
  • Server-side rendering: guard for window/ResizeObserver existence; measure on mount.
  • Content with interactive elements: make sure dragging doesn’t block clicks; start drag only after a small move threshold.
  • Keyboard: Arrow keys, Home/End (optional), Tab order, focus visible.
  • Touch: quick swipes vs. slow drags; threshold correctness.
  • Screen readers: confirm announcements when changing slides via controls.
  • Performance: check for jank on mid-range devices; throttle CPU in DevTools.
  • Responsiveness: rotate devices, change viewport sizes, try zoom at 200%+.

When to use a library

If you need advanced features quickly—momentum physics, complex loop strategies, virtualized slides, nested carousels—reach for a well-tested library. Otherwise, the two approaches here cover most product needs and keep bundle size modest.

Putting it all together

  • Use scroll-snap when you want speed, simplicity, and native behavior.
  • Use the transform approach when you need draggable control, autoplay, and optional looping.
  • Always prioritize accessibility: labels, keyboard support, and reduced-motion.
  • Optimize images and render only what’s necessary.

With these patterns in your toolkit, you can ship a reliable, performant React carousel that feels great on mouse, touch, and keyboard—without overengineering.

Related Posts