Loading…
Loading…
Displays a series of content items that users can cycle through horizontally.
The Carousel (also called Slider, Slideshow, or Swiper) presents a series of content items — images, cards, testimonials, or product listings — in a horizontally scrollable container that users cycle through one or a few items at a time.
Carousels are among the most debated components in UI design. Research consistently shows that auto-playing carousels on hero sections have abysmally low engagement (often < 1% click-through on slides beyond the first). However, when used correctly — user-initiated, clearly navigable, and focused — carousels are effective for browsing collections: product galleries, image portfolios, or related content suggestions.
When to use a Carousel:
When NOT to use a Carousel:
Carousel animations — the slide, fade, or scale transitions between items — should be smooth and purposeful. Use the Animation Tool to configure easing curves and durations. Slide transitions benefit from spring-based easing for a physical feel. Configure transitions to ensure motion respects user preferences.
| Variant | Description | Use Case |
|---|---|---|
| Single Slide | One item visible at a time. Full-width or container-width. | Hero images, testimonials, onboarding steps. |
| Multi-Slide | Multiple items visible, scrolling by one or a group. | Product listings, related content, card grids. |
| Peek / Overflow | Current slide is centered; adjacent slides peek from the edges. | Signals that more content exists, encourages swiping. |
| Fade | Items crossfade rather than slide. | Image galleries where spatial movement is unnecessary. |
| Coverflow | 3D perspective effect with the active slide centered and raised. | Media galleries, album art browsing. Heavily animated — use Animation Tool. |
| Thumbnail Navigation | A secondary strip of thumbnail images for direct slide selection. | Product photo galleries, real estate listings. |
| Vertical | Items scroll vertically instead of horizontally. | Story-style content (Instagram stories), full-screen mobile feeds. |
| Navigation | Description |
|---|---|
| Arrow Buttons | Previous/Next buttons on the sides of the carousel. Always include for keyboard/mouse users. |
| Dot Indicators | Row of dots below the carousel showing position and total count. Click to jump to a slide. |
| Thumbnail Strip | Clickable thumbnails for direct navigation to any slide. |
| Progress Bar | A thin bar showing progression through slides. Useful for auto-playing carousels. |
| Counter | Text showing "3 / 12" for current position. Preferred when there are many slides. |
| Swipe Only | No visible controls, touch/drag only. Avoid — lacks discoverability and accessibility. |
| Property | Type | Default | Description |
|---|---|---|---|
items | ReactNode[] | — | Array of slide content |
slidesPerView | number | 'auto' | 1 | Number of visible slides |
spaceBetween | number | 0 | Gap (px) between slides |
loop | boolean | false | Infinite loop navigation |
autoplay | boolean | { delay: number; pauseOnHover: boolean } | false | Automatic slide advancement |
navigation | boolean | true | Show prev/next arrow buttons |
pagination | 'dots' | 'fraction' | 'progress' | false | 'dots' | Slide position indicator style |
effect | 'slide' | 'fade' | 'coverflow' | 'cube' | 'slide' | Transition effect between slides |
speed | number | 300 | Transition duration in ms. Preview with Transition Tool. |
draggable | boolean | true | Enable touch/mouse drag navigation |
breakpoints | Record<number, Partial<CarouselProps>> | — | Responsive overrides at viewport widths |
a11y | { prevSlideMessage: string; nextSlideMessage: string } | — | Accessible labels for navigation |
onSlideChange | (index: number) => void | — | Callback when active slide changes |
Important: If autoplay is enabled, you must provide a visible pause/play button. This is a WCAG SC 2.2.2 requirement, not a suggestion. Users with cognitive or motor impairments need the ability to stop automatic content changes.
| Token Category | Token Example | Carousel Usage |
|---|---|---|
| Color – Surface | --color-surface-default | Slide container background |
| Color – Navigation | --color-neutral-700 | Arrow button icon fill |
| Color – Navigation BG | --color-surface-elevated | Arrow button background (semi-transparent) |
| Color – Indicator Active | --color-primary-600 | Active dot/progress fill |
| Color – Indicator Inactive | --color-neutral-300 | Inactive dot fill |
| Spacing | --space-4 | Gap between slides (spaceBetween) |
| Border Radius | --radius-full | Dot indicators (circular) |
| Border Radius | --radius-lg | Slide content rounding (card-style) |
| Shadow | --shadow-md | Navigation buttons floating over content |
| Transition | --duration-normal (300ms), --ease-out | Slide transition. Configure with Transition Tool and Animation Tool. |
| Z-Index | --z-overlay (10) | Navigation buttons above slide content |
Dot indicators should be at least 8×8px (44×44px touch target including padding) per WCAG SC 2.5.5 (Target Size). Small dots are fine visually, but the clickable area must be large enough for touch interaction.
| State | Description | Visual Treatment |
|---|---|---|
| Idle | No interaction. Showing current slide(s). | Static display. Autoplay timer running if enabled. |
| Dragging | User is touch/mouse dragging between slides. | Slides follow the pointer with momentum. Snap to nearest slide on release. Disable autoplay during drag. |
| Transitioning | Animating between slides. | Slide/fade/scale animation in progress. Duration 200–500ms. Use Animation Tool for easing. |
| At Start | First slide is active (non-looping mode). | Previous button is disabled (aria-disabled="true") or hidden. Visual dimming. |
| At End | Last slide is active (non-looping mode). | Next button is disabled or hidden. |
| Autoplay Running | Auto-advancing with timer. | Progress indicator filling. Play/pause button shows pause icon. |
| Autoplay Paused | Auto-advance paused by user interaction. | Progress indicator paused. Play/pause button shows play icon. |
| Loading | Slide content (images) still loading. | Skeleton or blurred placeholder. loading="lazy" on off-screen slide images. |
Drag physics: On release, calculate the drag velocity. If the velocity exceeds a threshold (e.g., 0.5px/ms), fling to the next slide regardless of drag distance. If below the threshold, snap back or forward based on the 50% crossing point. This creates a natural, momentum-based feel.
Carousels are notoriously problematic for accessibility. Doing them right requires careful attention to keyboard navigation, screen reader announcements, and motion preferences.
Semantic Structure:
role="region" with aria-roledescription="carousel" and an aria-label (e.g., "Product images").role="group" with aria-roledescription="slide" and aria-label (e.g., "Slide 3 of 12").aria-label="Previous slide" and aria-label="Next slide".tablist with tab roles, or as a group of radio buttons.WCAG Compliance:
aria-current="true" on the active dot.Motion Preferences:
When prefers-reduced-motion: reduce is active, disable all slide animations — switch to instant transitions (no slide or fade). Also disable autoplay entirely. Some users set reduced motion because animation triggers vestibular disorders; an auto-advancing carousel is the worst offender.
Screen Reader Announcements:
Use aria-live="polite" on a visually hidden element that announces slide changes: "Slide 3 of 12: [slide title]". Do not announce every autoplay transition — only user-initiated navigation.
Do:
loading="lazy" on images in non-visible slides to improve performance.Don't:
scroll-snap-type is lighter and more accessible.Performance tip: For image-heavy carousels, load only the current slide and its immediate neighbors (slidesPerView + 2). Use IntersectionObserver or the loading="lazy" attribute. Decode images off-thread with img.decode() to avoid frame drops during transitions.
<!-- Accessible Carousel -->
<section class="carousel"
role="region"
aria-roledescription="carousel"
aria-label="Customer testimonials">
<!-- Slide Container -->
<div class="carousel-viewport">
<div class="carousel-track" style="--slide-count: 4;">
<div class="carousel-slide" role="group"
aria-roledescription="slide" aria-label="Slide 1 of 4">
<blockquote>
<p>"This product changed everything for our team."</p>
<cite>— Jane Smith, Acme Corp</cite>
</blockquote>
</div>
<div class="carousel-slide" role="group"
aria-roledescription="slide" aria-label="Slide 2 of 4">
<blockquote>
<p>"Incredible performance and support."</p>
<cite>— John Doe, Beta Inc</cite>
</blockquote>
</div>
<!-- more slides... -->
</div>
</div>
<!-- Navigation Arrows -->
<button class="carousel-prev" aria-label="Previous slide">
<svg aria-hidden="true"><!-- left arrow --></svg>
</button>
<button class="carousel-next" aria-label="Next slide">
<svg aria-hidden="true"><!-- right arrow --></svg>
</button>
<!-- Dot Indicators -->
<div class="carousel-dots" role="tablist" aria-label="Slide navigation">
<button role="tab" aria-selected="true" aria-label="Go to slide 1"
class="carousel-dot carousel-dot--active"></button>
<button role="tab" aria-selected="false" aria-label="Go to slide 2"
class="carousel-dot"></button>
<button role="tab" aria-selected="false" aria-label="Go to slide 3"
class="carousel-dot"></button>
<button role="tab" aria-selected="false" aria-label="Go to slide 4"
class="carousel-dot"></button>
</div>
<!-- Live Region for Announcements -->
<div class="sr-only" aria-live="polite" aria-atomic="true">
Slide 1 of 4: "This product changed everything for our team."
</div>
</section>
<style>
.carousel-viewport {
overflow: hidden;
}
.carousel-track {
display: flex;
transition: transform 300ms ease-out;
}
.carousel-slide {
flex: 0 0 100%;
min-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.carousel-track {
transition: none;
}
}
</style>// Carousel Component
import { useState, useCallback, useEffect, useRef } from 'react';
interface CarouselProps {
children: React.ReactNode[];
slidesPerView?: number;
spaceBetween?: number;
loop?: boolean;
autoplay?: false | { delay: number; pauseOnHover?: boolean };
navigation?: boolean;
pagination?: 'dots' | 'fraction' | false;
speed?: number;
ariaLabel: string;
onSlideChange?: (index: number) => void;
}
function Carousel({
children,
slidesPerView = 1,
spaceBetween = 0,
loop = false,
autoplay = false,
navigation = true,
pagination = 'dots',
speed = 300,
ariaLabel,
onSlideChange,
}: CarouselProps) {
const [current, setCurrent] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const totalSlides = children.length;
const liveRef = useRef<HTMLDivElement>(null);
const prefersReducedMotion = usePrefersReducedMotion();
const goTo = useCallback((index: number) => {
const next = loop
? (index + totalSlides) % totalSlides
: Math.max(0, Math.min(index, totalSlides - 1));
setCurrent(next);
onSlideChange?.(next);
}, [loop, totalSlides, onSlideChange]);
// Autoplay
useEffect(() => {
if (!autoplay || isPaused || prefersReducedMotion) return;
const timer = setInterval(() => goTo(current + 1), autoplay.delay);
return () => clearInterval(timer);
}, [autoplay, current, isPaused, goTo, prefersReducedMotion]);
// Keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowLeft') goTo(current - 1);
if (e.key === 'ArrowRight') goTo(current + 1);
};
const transitionDuration = prefersReducedMotion ? 0 : speed;
return (
<section
className="carousel"
role="region"
aria-roledescription="carousel"
aria-label={ariaLabel}
onKeyDown={handleKeyDown}
onMouseEnter={() => autoplay && setIsPaused(true)}
onMouseLeave={() => autoplay && setIsPaused(false)}
onFocus={() => autoplay && setIsPaused(true)}
>
<div className="carousel-viewport">
<div
className="carousel-track"
style={{
transform: `translateX(-${current * (100 / slidesPerView)}%)`,
transition: `transform ${transitionDuration}ms ease-out`,
gap: spaceBetween,
}}
>
{children.map((child, i) => (
<div
key={i}
className="carousel-slide"
role="group"
aria-roledescription="slide"
aria-label={`Slide ${i + 1} of ${totalSlides}`}
style={{ flex: `0 0 ${100 / slidesPerView}%` }}
inert={i !== current ? '' : undefined}
>
{child}
</div>
))}
</div>
</div>
{navigation && (
<>
<button className="carousel-prev"
aria-label="Previous slide"
onClick={() => goTo(current - 1)}
disabled={!loop && current === 0}>
←
</button>
<button className="carousel-next"
aria-label="Next slide"
onClick={() => goTo(current + 1)}
disabled={!loop && current === totalSlides - 1}>
→
</button>
</>
)}
{pagination === 'dots' && (
<div className="carousel-dots" role="tablist" aria-label="Slide navigation">
{children.map((_, i) => (
<button key={i} role="tab"
aria-selected={i === current}
aria-label={`Go to slide ${i + 1}`}
className={`carousel-dot ${i === current ? 'active' : ''}`}
onClick={() => goTo(i)} />
))}
</div>
)}
{pagination === 'fraction' && (
<div className="carousel-fraction" aria-live="polite">
{current + 1} / {totalSlides}
</div>
)}
{autoplay && (
<button className="carousel-pause"
aria-label={isPaused ? 'Play slideshow' : 'Pause slideshow'}
onClick={() => setIsPaused(!isPaused)}>
{isPaused ? '▶' : '⏸'}
</button>
)}
<div ref={liveRef} className="sr-only" aria-live="polite" aria-atomic="true" />
</section>
);
}
// Usage
<Carousel ariaLabel="Customer testimonials" pagination="dots" speed={400}>
<TestimonialCard author="Jane Smith" quote="Changed everything." />
<TestimonialCard author="John Doe" quote="Incredible support." />
<TestimonialCard author="Alice Wang" quote="Best tool we've used." />
</Carousel>Swiper (swiperjs.com) is the de facto standard carousel library, used by millions of sites. It provides slidesPerView, spaceBetween, loop, autoplay, navigation, pagination (dots/fraction/progressbar/custom), effect (slide/fade/cube/coverflow/flip/creative), speed, breakpoints, freeMode, thumbs (thumbnail navigation), zoom, virtual (virtual rendering for hundreds of slides), and a11y (built-in ARIA attributes). Swiper's React wrapper (swiper/react) provides <Swiper> and <SwiperSlide> components. Transition effects are GPU-accelerated via CSS transforms. Use the Animation Tool and Transition Tool to match Swiper's easing to your design system.
Embla Carousel is a lightweight, extensible carousel library focused on performance and accessibility. It provides a headless core with plugins for autoplay, auto-scroll, auto-height, class names, fade, and wheel gestures. Embla's API is imperative (embla.scrollTo(), embla.scrollNext()) with event listeners (on('select'), on('scroll')). Its bundle size (~3KB gzipped) is significantly smaller than Swiper (~40KB). Embla is the recommended choice for custom design systems where you want full control over markup and styling.
Material Design 3 does not provide a dedicated Carousel component in MUI. MUI developers typically use Swiper, Embla, or react-slick. Google's Material Design guidelines include carousel specs with three variants: Multi-browse (shows multiple items with varying sizes), Uncontained (items extend to the screen edge), and Hero (single prominent item). These specs emphasize that carousels should reveal content gradually and respond to swipe gestures.
Ant Design provides a Carousel component (wrapping react-slick internally) with autoplay, dots, dotPosition (top/bottom/left/right), effect (scrollx/fade), afterChange, beforeChange, and all react-slick props via spread. Ant's implementation is mature but tightly coupled to react-slick's API.
Chakra UI and Radix UI do not provide carousel primitives. Chakra recommends composing with Embla or Swiper. Radix's stance is that carousels involve too many application-specific decisions (content type, navigation style, autoplay behavior) to standardize as a headless primitive.
Headless UI does not include a carousel component.
react-aria (Adobe) provides useCarousel hooks that handle keyboard navigation, ARIA attributes, and focus management without any visual implementation — ideal for fully custom carousels built on top of CSS scroll-snap. This is the most accessible-by-default approach.