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.
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:
- Scroll-snap carousel: uses native scrolling + CSS snap points. Less JS, highly accessible, great for most use cases.
- Transform-based carousel: uses translateX for precise animation, optional drag physics and loop mode.
Approach 1: Scroll‑snap carousel (simple and accessible)
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.
Approach 2: Transform‑based carousel with a custom hook
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.
Testing your carousel
- 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
Building Effective React Skeleton Loading UIs: Patterns, Code, and A11y Tips
Build accessible, performant skeleton loading UIs in React with patterns, Suspense, CSS shimmer, and testing tips.
React Search Autocomplete: A Fast, Accessible Implementation with Hooks
Build a fast, accessible React search autocomplete with hooks, debouncing, ARIA, caching, and virtualization. Includes complete, production-ready code.
Mastering Complex State in React with useReducer
A practical guide to complex React state with useReducer: modeling, actions, async flows, context patterns, performance, and testing.