Skip to content

Carousel

Displays a series of content items that users can cycle through horizontally.

  • Slider
  • Slideshow
  • Swiper

Overview

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:

  • Product image galleries (e-commerce detail pages)
  • Testimonial or review sections where space is limited
  • Related content or "you may also like" suggestions
  • Portfolio or case study showcases
  • Onboarding flows with sequential slides (mobile)
  • Multi-image social media-style posts

When NOT to use a Carousel:

  • For hero banner rotation — users rarely engage past the first slide. Use a static hero or A/B test your best single option instead.
  • For critical content the user must see — if it matters, don't hide it behind a swipe.
  • For navigation — use Tabs or a Navigation Bar
  • When all items should be visible simultaneously — use a scrollable Grid or List

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.

Variants

Common Carousel Variants

VariantDescriptionUse Case
Single SlideOne item visible at a time. Full-width or container-width.Hero images, testimonials, onboarding steps.
Multi-SlideMultiple items visible, scrolling by one or a group.Product listings, related content, card grids.
Peek / OverflowCurrent slide is centered; adjacent slides peek from the edges.Signals that more content exists, encourages swiping.
FadeItems crossfade rather than slide.Image galleries where spatial movement is unnecessary.
Coverflow3D perspective effect with the active slide centered and raised.Media galleries, album art browsing. Heavily animated — use Animation Tool.
Thumbnail NavigationA secondary strip of thumbnail images for direct slide selection.Product photo galleries, real estate listings.
VerticalItems scroll vertically instead of horizontally.Story-style content (Instagram stories), full-screen mobile feeds.

Navigation Variants

NavigationDescription
Arrow ButtonsPrevious/Next buttons on the sides of the carousel. Always include for keyboard/mouse users.
Dot IndicatorsRow of dots below the carousel showing position and total count. Click to jump to a slide.
Thumbnail StripClickable thumbnails for direct navigation to any slide.
Progress BarA thin bar showing progression through slides. Useful for auto-playing carousels.
CounterText showing "3 / 12" for current position. Preferred when there are many slides.
Swipe OnlyNo visible controls, touch/drag only. Avoid — lacks discoverability and accessibility.

Autoplay Variants

  • No autoplay (recommended): User controls all navigation. Best for engagement and accessibility.
  • Autoplay with pause on hover/focus: Slides advance automatically but stop when the user interacts. Must provide pause/play button.
  • Autoplay with progress indicator: A progress bar fills as the slide timer counts down, giving users a sense of timing.

Properties

Carousel Properties

PropertyTypeDefaultDescription
itemsReactNode[]—Array of slide content
slidesPerViewnumber | 'auto'1Number of visible slides
spaceBetweennumber0Gap (px) between slides
loopbooleanfalseInfinite loop navigation
autoplayboolean | { delay: number; pauseOnHover: boolean }falseAutomatic slide advancement
navigationbooleantrueShow prev/next arrow buttons
pagination'dots' | 'fraction' | 'progress' | false'dots'Slide position indicator style
effect'slide' | 'fade' | 'coverflow' | 'cube''slide'Transition effect between slides
speednumber300Transition duration in ms. Preview with Transition Tool.
draggablebooleantrueEnable touch/mouse drag navigation
breakpointsRecord<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 Mappings

Design Token Mappings

Token CategoryToken ExampleCarousel Usage
Color – Surface--color-surface-defaultSlide container background
Color – Navigation--color-neutral-700Arrow button icon fill
Color – Navigation BG--color-surface-elevatedArrow button background (semi-transparent)
Color – Indicator Active--color-primary-600Active dot/progress fill
Color – Indicator Inactive--color-neutral-300Inactive dot fill
Spacing--space-4Gap between slides (spaceBetween)
Border Radius--radius-fullDot indicators (circular)
Border Radius--radius-lgSlide content rounding (card-style)
Shadow--shadow-mdNavigation buttons floating over content
Transition--duration-normal (300ms), --ease-outSlide 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.

States

Carousel States

StateDescriptionVisual Treatment
IdleNo interaction. Showing current slide(s).Static display. Autoplay timer running if enabled.
DraggingUser is touch/mouse dragging between slides.Slides follow the pointer with momentum. Snap to nearest slide on release. Disable autoplay during drag.
TransitioningAnimating between slides.Slide/fade/scale animation in progress. Duration 200–500ms. Use Animation Tool for easing.
At StartFirst slide is active (non-looping mode).Previous button is disabled (aria-disabled="true") or hidden. Visual dimming.
At EndLast slide is active (non-looping mode).Next button is disabled or hidden.
Autoplay RunningAuto-advancing with timer.Progress indicator filling. Play/pause button shows pause icon.
Autoplay PausedAuto-advance paused by user interaction.Progress indicator paused. Play/pause button shows play icon.
LoadingSlide 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.

Accessibility

Accessibility Requirements

Carousels are notoriously problematic for accessibility. Doing them right requires careful attention to keyboard navigation, screen reader announcements, and motion preferences.

Semantic Structure:

  • The carousel container should use role="region" with aria-roledescription="carousel" and an aria-label (e.g., "Product images").
  • Each slide should use role="group" with aria-roledescription="slide" and aria-label (e.g., "Slide 3 of 12").
  • Navigation buttons need aria-label="Previous slide" and aria-label="Next slide".
  • Dot indicators should be implemented as a tablist with tab roles, or as a group of radio buttons.

WCAG Compliance:

  • SC 1.4.3 (Contrast – Minimum): Navigation arrows and dot indicators must meet 4.5:1 contrast when overlaid on slide content. Use a semi-transparent background behind arrows to guarantee contrast regardless of slide content. Verify with Contrast Checker.
  • SC 1.4.11 (Non-text Contrast): Arrow icons and dot indicators are non-text UI elements requiring 3:1 contrast.
  • SC 2.1.1 (Keyboard): Users must be able to navigate slides with keyboard. Left/Right arrows should move between slides when the carousel is focused. Tab should move between interactive elements within the current slide.
  • SC 2.2.2 (Pause, Stop, Hide): If autoplay is enabled, provide a visible pause mechanism. The carousel must stop auto-advancing when any element within it receives keyboard focus.
  • SC 2.4.7 (Focus Visible): Arrow buttons and dot indicators must show visible focus rings.
  • SC 2.5.5 (Target Size): Dot indicators and arrow buttons must have at least 24×24px touch targets (44×44px recommended for Level AAA).
  • SC 1.3.1 (Info and Relationships): The current slide position must be programmatically determinable — use 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.

Usage Guidelines

Usage Guidelines

Do:

  • Default to user-controlled navigation. Let users swipe, click arrows, or click dots.
  • Show navigation affordances (arrows, dots) on all devices. "Swipe only" carousels fail discoverability tests.
  • Use loading="lazy" on images in non-visible slides to improve performance.
  • Provide enough visual cues that more content exists — peek the next slide's edge, use dot indicators, or show a counter.
  • Pause autoplay on hover, focus, and any user interaction with the carousel.
  • Use the "peek" variant for touch devices — partially visible adjacent slides are the strongest affordance for swipe navigation.

Don't:

  • Don't use auto-playing carousels for critical content. Studies from NN/g show users treat auto-playing carousels like banner ads — they're ignored.
  • Don't use carousels to avoid making content hierarchy decisions. If everything is "important," nothing is.
  • Don't exceed 5–8 slides. Engagement drops dramatically beyond 3 slides — by slide 5, you're talking to < 2% of users.
  • Don't use a carousel when a simple scrollable row of cards would work. Horizontal scroll with CSS scroll-snap-type is lighter and more accessible.
  • Don't disable swipe on touch devices — it's the most natural interaction pattern.
  • Don't use excessively slow transitions. 300–500ms is the sweet spot. Anything over 700ms feels sluggish.
  • Don't nest carousels (a carousel slide containing another carousel). It's a UX and accessibility nightmare.

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.

Code Snippets

html
<!-- 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>
tsx
// 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>

Design Systems

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.

LayoutSliderSlideshowSwiper