Carousel

@use 'sprucecss/scss/spruce' as *;

.carousel {
  display: grid;
  gap: spacer('s');

  &__controls {
    display: flex;
    gap: spacer('xs');
    justify-content: flex-end;
  }

  &__track {
    @include clear-list;
    @include layout-reel(85%, 's');
    border-radius: radius('lg');
    scrollbar-width: none;

    @include motion-safe {
      scroll-behavior: smooth;
    }

    &:focus-visible {
      outline: 2px solid color('focus-ring');
      outline-offset: 2px;
    }

    > * + * {
      margin-block-start: 0;
    }

    // The items sit side by side: clear the stack margins of the base list style.
    > li {
      margin-block: 0;
      padding-block: 0;
    }
  }

  &__slide {
    figure {
      margin: 0;
      position: relative;
    }

    img {
      aspect-ratio: 3 / 2;
      border-radius: radius('lg');
      inline-size: 100%;
      object-fit: cover;
    }

    figcaption {
      background-color: color-alpha('background', 0.85);
      border-radius: radius('sm');
      color: color('heading');
      font-weight: 700;
      inset-block-end: spacer('s');
      inset-inline-start: spacer('s');
      margin: 0;
      padding: spacer('xxs') spacer('xs');
      position: absolute;
    }
  }

  &__dots {
    display: flex;
    gap: spacer('xs');
    justify-content: center;
  }

  &__dot {
    background-color: color('border');
    block-size: 0.625rem;
    border-radius: radius('pill');
    inline-size: 0.625rem;
    @include transition($property: 'background-color, inline-size');

    &[aria-current='true'] {
      background-color: color('primary');
      inline-size: 1.5rem;
    }
  }
}
<section class="carousel" aria-roledescription="carousel" aria-label="Landscapes">
    <div class="carousel__controls">
        <button class="btn btn--outline-primary btn--icon btn--sm" type="button" data-carousel-prev aria-label="Previous slide">
            <svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m15 18-6-6 6-6"/></svg>
        </button>
        <button class="btn btn--outline-primary btn--icon btn--sm" type="button" data-carousel-next aria-label="Next slide">
            <svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m9 18 6-6-6-6"/></svg>
        </button>
    </div>
    <ul class="carousel__track" role="list" tabindex="0" aria-label="Slides">
        <li class="carousel__slide" id="carousel-slide-1" aria-roledescription="slide" aria-label="1 of 4">
            <figure>
                <img src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=900&q=70" alt="Snowy peaks at sunrise." width="900" height="600" loading="lazy">
                <figcaption>Mountains</figcaption>
            </figure>
        </li>
        <li class="carousel__slide" id="carousel-slide-2" aria-roledescription="slide" aria-label="2 of 4">
            <figure>
                <img src="https://images.unsplash.com/photo-1448375240586-882707db888b?auto=format&fit=crop&w=900&q=70" alt="Sunlight through tall trees." width="900" height="600" loading="lazy">
                <figcaption>Forest</figcaption>
            </figure>
        </li>
        <li class="carousel__slide" id="carousel-slide-3" aria-roledescription="slide" aria-label="3 of 4">
            <figure>
                <img src="https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=900&q=70" alt="Turquoise water on a quiet beach." width="900" height="600" loading="lazy">
                <figcaption>Coast</figcaption>
            </figure>
        </li>
        <li class="carousel__slide" id="carousel-slide-4" aria-roledescription="slide" aria-label="4 of 4">
            <figure>
                <img src="https://images.unsplash.com/photo-1509316785289-025f5b846b35?auto=format&fit=crop&w=900&q=70" alt="Dunes under a clear sky." width="900" height="600" loading="lazy">
                <figcaption>Desert</figcaption>
            </figure>
        </li>
    </ul>
    <nav class="carousel__dots" aria-label="Choose a slide">
        <a class="carousel__dot" href="#carousel-slide-1" aria-label="Go to slide 1"></a>
        <a class="carousel__dot" href="#carousel-slide-2" aria-label="Go to slide 2"></a>
        <a class="carousel__dot" href="#carousel-slide-3" aria-label="Go to slide 3"></a>
        <a class="carousel__dot" href="#carousel-slide-4" aria-label="Go to slide 4"></a>
    </nav>
</section>
(() => {
  document.querySelectorAll('.carousel').forEach((carousel) => {
    const track = carousel.querySelector('.carousel__track');
    const slides = [...carousel.querySelectorAll('.carousel__slide')];
    const dots = [...carousel.querySelectorAll('.carousel__dot')];
    let current = 0;

    const goTo = (index) => {
      const slide = slides[Math.max(0, Math.min(index, slides.length - 1))];
      track.scrollTo({ left: slide.offsetLeft - track.offsetLeft });
    };

    carousel.querySelector('[data-carousel-prev]')?.addEventListener('click', () => goTo(current - 1));
    carousel.querySelector('[data-carousel-next]')?.addEventListener('click', () => goTo(current + 1));

    dots.forEach((dot, index) => {
      dot.addEventListener('click', (event) => {
        event.preventDefault();
        goTo(index);
      });
    });

    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          current = slides.indexOf(entry.target);
          dots.forEach((dot, index) => dot.setAttribute('aria-current', String(index === current)));
        }
      });
    }, { root: track, threshold: 0.6 });

    slides.forEach((slide) => observer.observe(slide));
  });
})();

Carousel

On this page

A lightweight, swipeable carousel built on CSS scroll snapping. It works without JavaScript; the script only adds the previous/next buttons and the active dot.

Technical Details

  • The track is a horizontally scrolling list using the new layout-reel mixin (scroll-snap-type: inline mandatory), so touch, trackpad and keyboard scrolling work natively.
  • The track is focusable (tabindex="0"), so keyboard users can scroll it with the arrow keys.
  • The dots are anchor links to the slides; the script keeps aria-current on the visible slide's dot using an IntersectionObserver.
  • Slides use aria-roledescription="slide" and an "N of M" label. There is no autoplay on purpose.
  • Smooth scrolling is turned on only when the user doesn't prefer reduced motion.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.