DEV Community

Cover image for Vanilla JS carousel that is accessible, swipeable, infinite-scrolling, and autoplaying
Masa Kudamatsu
Masa Kudamatsu

Posted on • Updated on • Originally published at Medium

Vanilla JS carousel that is accessible, swipeable, infinite-scrolling, and autoplaying

TL;DR

A carousel that is accessible, swipeable, infinite-scrolling, and autoplaying can be coded from scratch with vanilla JS as follows.

For accessibility, implement instructions by the ARIA Authoring Practices Guide (see Sections 1 and 3 of this article).

For swipeability, use CSS Scroll Snap (Section 2).

For infinite scrolling, duplicate the first (last) slide, place it to the right (left) of the last (first) slide, and use the scrollTo() method to instantly “scroll” to the original one for creating an illusion of infinite scrolling (Section 4).

For autoplaying, use the setInterval() method. To stop autoplaying for good user experiences, employ the Intersection Observer API and the event listeners for pointerenter, focus, touchstart, and resize events (Section 5).

The exact code can be found in the CodePen demo for this article.

Why do I write this article?

There is a bunch of articles on how to build a carousel from scratch with vanilla JavaScript. But as far as I know, no one writes about a particular type of carousels: (1) accessible, (2) swipeable, (3) infinite-scrolling, and (4) autoplaying.

W3C Web Accessibility Initiative (2024b) explains how to build a carousel that is (1) accessible, (3) infinite-scrolling, and (4) autoplaying. But it is not swipeable.

A carousel by van der Schee (2021) is (2) swipeable and (4) autoplaying, which is beautifully done. But it’s not accessible or infinite-scrolling.

Buljan (2022) provides a code snippet for (3) infinite-scrolling and (4) autoplaying carousels with the use of CSS animation. It’s an elegant snippet of code, but it’s not accessible or swipeable.

This article grabs the best bits of code from these three carousels and brings them together to create a carousel that is accessible, swipeable, infinite-scrolling, and autoplaying.

A photo of a rock garden with autumn leaves. Below the photo, four circular dots are shown side by side with the third one from the left marked in blue and the rest in grey.
The carousel to be built in this article, with the third slide shown

1. HTML for accessibility

This section heavily relies on the ARIA Authoring Practices Guide (W3C Web Accessibility Initiative 2024a and 2024b).

There are various websites for web accessibility, but as far as I can see, the ARIA Authoring Practices Guide is the most authoritative.

1.1 Carousel container

We start with a container <div> with class="carousel":

<div 
  class="carousel"
  role="group" 
  aria-label="Ryoan-ji Temple’s Rock Garden" 
  aria-roledescription="carousel" 
>
</div>
Enter fullscreen mode Exit fullscreen mode

For accessibility, three attributes are necessary:

  1. role="group" tells screen readers that all the child elements have related functionality to work together as one UI object (MDN contributors 2024a)
  2. aria-label tells screen readers what the carousel is about. You can use aria-labelledby instead if the carousel title is another HTML element inside the carousel container.
  3. aria-roledescription="carousel" tells screen readers that the <div> element is a carousel. According to W3C Web Accessibility Initiative (2024b), if the web page is in a language other than English, use the corresponding word in that language instead of "carousel" (e.g., aria-roledescription="スライダー" in Japanese).

According to W3C Web Accessibility Initiative (2024a), the role="group" should not be used, however, if the carousel is a landmark region, that is, a top-level section of the page along side <header>, <footer>, <main>, <aside>, <nav>, and <form> (see W3C Web Accessibility Initiative 2024c for more on landmark regions). In this case, use <section>:

<section
  class="carousel"
  aria-label="Ryoan-ji Temple’s Rock Garden" 
  aria-roledescription="carousel" 
>
</section>
Enter fullscreen mode Exit fullscreen mode

Below we assume our carousel is not a landmark region, which is likely to be true in most cases.

1.2 Slide wrapper

Inside the carousel container, we first add a slide wrapper <div> with class="carousel__slides" (where I adopt the BEM convention to name classes):

<div 
  class="carousel"
  role="group" 
  aria-label="Ryoan-ji Temple’s Rock Garden" 
  aria-roledescription="carousel" 
>
  <!-- ADDED FROM HERE -->
  <div
    class="carousel__slides"
    aria-atomic="false" 
    aria-live="off" 
  >
  </div>
  <!-- ADDED UNTIL HERE -->
</div>
Enter fullscreen mode Exit fullscreen mode

For accessibility, we need two ARIA attributes:

  • aria-atomic="false" tells screen readers that, when its text content gets updated, they should announce only the updated part rather than the entire content (MDN contributors 2024b).
  • aria-live="off" tells screen readers not to announce text content when it is updated. This is because, when the carousel autoplays, screen reader users do not want to hear the updated text content every time the next slide appears while they are reading another part of the page (W3C Web Accessibility Initiative 2024b).

The aria-live attribute value should be replaced with polite when the carousel autoplay is disabled. This way, every time the screen reader users manually switch the slide, the updated content does get announced.

We will use vanilla JS to toggle the aria-live value whenever autoplay is turned off (and on) in Sections 5.2 and 5.3 below.

1.3 Slide containers

Let’s add four slide containers. Of course, you can add more than 4 slides. But for the ease of exposition, I stick to 4 slides throughout this article.

<div 
  class="carousel"
  role="group" 
  aria-label="Ryoan-ji Temple’s Rock Garden" 
  aria-roledescription="carousel" 
>
  <div
    class="carousel__slides"
    aria-atomic="false" 
    aria-live="off" 
  >
    <!-- ADDED FROM HERE -->
    <div
      class="carousel__slide"
      role="group"
      aria-label="1 of 4"
      aria-roledescription="slide"
    >
    </div>
    <div
      class="carousel__slide"
      role="group"
      aria-label="2 of 4"
      aria-roledescription="slide"
    >
    </div>
    <div
      class="carousel__slide"
      role="group"
      aria-label="3 of 4"
      aria-roledescription="slide"
    >
    </div>
    <div
      class="carousel__slide"
      role="group"
      aria-label="4 of 4"
      aria-roledescription="slide"
    >
    </div>
    <!-- ADDED UNTIL HERE -->
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Each slide container <div class="carousel__slide"> should have three attributes for accessibility:

  • role="group" tells screen readers that its child elements constitute a single slide as a whole.
  • aria-label identifies each slide. In the above example, we use "1 of 4" etc. to tell screen reader users which slide they are reading right now. However, you can use any other string to identify each slide.
  • aria-roledescription="slide" tells screen readers that the <div> element is a slide. According to W3C Web Accessibility Initiative (2024b), if the web page is in a language other than English, use the corresponding word in that language instead of "slide" (e.g., aria-roledescription="スライド" in Japanese).

1.4 Slide content

Each slide container can have anything as its child elements, ranging from a single <img> element to a set of elements that constitute an interactive card.

For the ease of exposition, we use a single <img> element throughout this article:

<div 
  class="carousel"
  role="group" 
  aria-label="Ryoan-ji Temple’s Rock Garden" 
  aria-roledescription="carousel" 
>
  <div
    class="carousel__slides"
    aria-atomic="false" 
    aria-live="off" 
  >
    <div
      class="carousel__slide"
      role="group"
      aria-label="1 of 4"
      aria-roledescription="slide"
    >
      <!-- ADDED FROM HERE -->
      <img src="https://translating-japanese-gardens.pages.dev/ryoanji/ryoanji-banner-spring-1882.jpg" width="991" height="702"/>
      <!-- ADDED UNTIL HERE -->
    </div>
    <div
      class="carousel__slide"
      role="group"
      aria-label="2 of 4"
      aria-roledescription="slide"
    >
      <!-- ADDED FROM HERE -->
      <img src="https://translating-japanese-gardens.pages.dev/ryoanji/ryoanji-banner-summer-1882.jpg" width="991" height="702"/>
      <!-- ADDED UNTIL HERE -->
    </div>
    <div
      class="carousel__slide"
      role="group"
      aria-label="3 of 4"
      aria-roledescription="slide"
    >
      <!-- ADDED FROM HERE -->
      <img src="https://translating-japanese-gardens.pages.dev/ryoanji/ryoanji-banner-autumn-1882.jpg" width="991" height="702"/>
      <!-- ADDED UNTIL HERE -->
    </div>
    <div
      class="carousel__slide"
      role="group"
      aria-label="4 of 4"
      aria-roledescription="slide"
    >
      <!-- ADDED FROM HERE -->
      <img src="https://translating-japanese-gardens.pages.dev/ryoanji/ryoanji-banner-winter-1882.jpg" width="991" height="702"/>
      <!-- ADDED UNTIL HERE -->
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

where I include four of my own photographs of Ryoan-ji Temple’s Rock Garden, a UNESCO World Heritage site in Kyoto. We should add alt text for accessibility and, if the carousel is below the fold, implement lazy loading with loading="lazy" for performance. But that’s not the main topic of this article. So I omit those considerations here.

1.5 Navigation dots

Those tiny dots to navigate through carousel slides are often called navigation dots (or navdots for short).

Many UX design experts say you should avoid nagivation dots because they are too small to click (e.g., Friedman 2022, who suggests several alternatives to nagivation dots). However, in my humble opinion, nagivation dots are ubiquitous enough to assume that they actually help the user realize what they are seeing is a carousel. So in this artciel I go with nagivation dots.

In terms of user interface design, navigation dots should be rendered below the slides because touch device users would otherwise find the slide hidden by their own hand when tapping a navigation dot.

However, for accessibility, navigation dots should come before slides in the DOM tree because screen reader users would otherwise need to go back to read the slide after pressing a navigation dot (Weckenmann 2023):

<div 
  class="carousel"
  role="group" 
  aria-label="Ryoan-ji Temple’s Rock Garden" 
  aria-roledescription="carousel" 
>
  <!-- ADDED FROM HERE -->
  <div
    class="carousel__navdots"
    role="group"
    aria-label="Choose slide to display"
  >
    <button type="button" aria-label="1 of 4" aria-disabled="true"></button>
    <button type="button" aria-label="2 of 4" aria-disabled="false"></button>
    <button type="button" aria-label="3 of 4" aria-disabled="false"></button> 
    <button type="button" aria-label="4 of 4" aria-disabled="false"></button> 
  </div>
  <!-- ADDED UNTIL HERE -->
  <div
    class="carousel__slides"
    aria-atomic="false" 
    aria-live="off" 
  >
    <!-- Omitted for brevity -->
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The navigation dot container needs the following two attributes for accessibility:

  • role="group" tells screen readers that its child elements work together as the navigation control
  • aria-label tells screen readers that what purpose this group of buttons serves for

Each navigation dot is then rendered as a <button type="button"> element with two ARIA attributes.

First, aria-label refers to the corresponding slide’s accessible name. Here you could in theory use aria-labelledby to refer to the id attribue value of each slide. However, perhaps surprisingly, the implementation of aria-labelledby differs across screen readers (PowerMapper 2023, as cited by GrahamTheDev 2020). It’s best to directly label each navigation dot.

Second, aria-disabled should be true for the navigation dot that corresponds to the current slide. It effectively tells screen reader users which slide is currently shown. We will toggle this value with vanilla JavaScript in Section 3.6 below.


That's all for how to make navigation dots accessible. Before moving on, however, let me discuss a couple of alternative approaches to mark up navigation dots.

First, navdots can be marked up with <a> elements as jump links to slides (by assigning the id attribute to each slide). See Coyier (2019) for a simple example. The swipeable and autoplaying carousel by van der Schee (2021) also uses this approach.

However, the jump-link approach is not convenient to create infinite-scrolling carousels as we will see in Section 4 below. This is why I go for <button> elements and use vanilla JavaScript to scroll the carousel.

Second, navigation dots can also be marked up as tabs. This approach is known as the "tabbed" pattern (as opposed to the "grouped" pattern adopted in this article). See W3C Web Accessibility Initiative (2024d) and Deque Systems (undated) for how to implement the "tabbed" pattern.

However, Webb (2021) reports that low-vision and blind users find the "tabbed" pattern very confusing. Given this piece of evidence, I believe the "grouped" pattern is better to mark up navigation dots.

2. CSS for swiping to slide

Now that we get HTML right for accessibility, let’s start working on CSS so that touch device users can swipe the carousel to see the next and previous slides.

2.1 Setting each slide width

First of all, set the width of the carousel and each slide:

.carousel {
  width: 991px;
}
.carousel__slides,
.carousel__slide {
  width: 100%;
}
Enter fullscreen mode Exit fullscreen mode

The above implements a carousel where each slide is 991px wide (because each image is 1882px wide) and only one slide is shown at a time.

If you want to show multiple slides (possibly partly hidden outside the window edges) on both sides of the center slide, rewrite the above CSS code as follows:

.carousel {
  width: 100%;
}
Enter fullscreen mode Exit fullscreen mode

where setting the carousel width is delegated to its parent element. Without explicitly specifying the width of the slide wrapper and each slide, the image’s intrinsic width will be the width of each slide while the slide wrapper takes the same width as the carousel container.

2.2 CSS Scroll Snap for slides

Here is the most important part of CSS to make a carousel swipeable:

.carousel__slides {
  display: flex;
  column-gap: 20px; /* optional */
  overflow: auto;
  scroll-snap-type: x mandatory;
}
.carousel__slide {
  flex: 0 0 auto;
  scroll-snap-align: center;
}
Enter fullscreen mode Exit fullscreen mode

The display: flex arranges slides horizontally while overflow: auto makes the slide wrapper scrollable to reveal overflown slides. The column-gap is optional: it allows you to add whitespace between a pair of slides. The flex: 0 0 auto makes sure that each slide won’t get magnified or shrunk unexpectedly due to the carousel’s width.

Crucial to display the current slide at the center of the slide wrapper is the pair of scroll-snap-type (for the slide wrapper) and scroll-snap-align: center (for each individual slide). This technique, known as CSS Scroll Snap, is well-documented elsewhere (e.g., Rifki (2020)).

This way, touch device users can swipe the carousel to the left, or to the right, to reveal slides initially hidden. CSS Scroll Snap makes sure that the slide is shown at the center after each swiping gesture.

Many carousel plugins had been created before all major browsers supported CSS Scroll Snap around the year of 2020. Which means they use JavaScript to achieve the same feature, causing a bulky bundle size and worsening the performance (cf. Hempenius 2021).

The most popular carousel library, Swiper, has updated its API to support CSS Scroll Snap with what it calls CSS mode. It seems to me that this causes Swiper’s API more complicated than necessary, which prompted me to build a carousel from scratch (and write this article).

2.3 Disable scroll bars

However, the above CSS code reveals a scroll bar. We do not need it as the navigation dots will serve the same purpose. Hide the scroll bar is, however, a little tricky:

.carousel__slides {
  scrollbar-width: none; /* for Firefox and latest Chromium */
}
.carousel__slides::-webkit-scrollbar {
  display: none; /* for Safari and legacy Chromium */
}
Enter fullscreen mode Exit fullscreen mode

The scrollbar-width property is supported only by Firefox (since late 2018) and those Chromium browsers released in early 2024 (Can I Use 2024). As a fallback, we need the ::-webkit-scrollbar pseudo element (see John 2023 for more detail).

3. CSS and JS for Navigation Dots

Let’s style navigation dots and make them accessible dynamically.

3.1 Positioning navigation dots

First, let’s position the navigation dots below the slides. In Section 1.5 above, we have written nagivation dots first and slides second in the HTML code. With CSS, we need to reverse this order.

.carousel {
  padding-bottom: 60px;
  position: relative;
}
.carousel__navdots {
  bottom: 0;
  position: absolute;
}
Enter fullscreen mode Exit fullscreen mode

The use of position: relative allows the navigation dots to be placed anywhere inside the carousel container. The padding-bottom creates a space below the slides for navigation dots. Its exact value can be anything to achieve your own design, of course.

Then, the navigation dot container is positioned on the lower edge of the carousel container (with bottom: 0).

Next, style the navigation dot container to set the layout of dots:

.carousel__navdots {
  bottom: 0;
  column-gap: 16px; /* ADDED */
  display: flex;  /* ADDED */
  justify-content: center;  /* ADDED */
  left: 0; /* ADDED */
  position: absolute;
  right: 0; /* ADDED */
}
Enter fullscreen mode Exit fullscreen mode

The flexbox along with column-gap allows navigation dots to be equally spaced (by 16px in this example). The justify-content: center and the pair of left: 0 and right: 0 center-aligns the navigation dots horizontally relative to the carousel container.

3.2 Styling each navigation dot

Second, style each individual navigation dot:

.carousel__navdots button {
  /* reset default button style */
  -moz-appearance: none;
  -webkit-apperance: none;
  appearance: none;
  border: 0;
  cursor: pointer;
  /* style as a grey dot */
  background-color: #9a9a9a; 
  border-radius: 50%;
  height: 10px;
  padding: 0;
  width: 10px;
}
Enter fullscreen mode Exit fullscreen mode

The first five CSS properties reset the default style of <button> elements (Shadeed 2020). The rest is up to you. Here each dot is a grey circle with the diameter of 10px.

3.3 Dynamic styling

Finally, style the focus and active states of navigation dots:

.carousel__navdots button:focus-visible {
  outline: 2px solid #0060a8; /* blue */
  outline-offset: 2px;
}
.carousel__navdots button.is-active {
  background-color: #0060a8; /* blue */
}
Enter fullscreen mode Exit fullscreen mode

The .is-active class will be added with JavaScript to indicate which slide is currently shown.

3.4 JS for click event handler

This and next subsections heavily rely on the technique proposed by Buljan (2022).

Let’s first collect the components of the carousel:

// Components
const carouselContainer = document.querySelector('.carousel');
const slideWrapper = document.querySelector('.carousel__slides');
const slides = document.querySelectorAll('.carousel__slide');
const navdotWrapper = document.querySelector('.carousel__navdots');
const navdots = document.querySelectorAll('.carousel__navdots button');
Enter fullscreen mode Exit fullscreen mode

Next, retrieve and/or set parameters:

// Parameters
const n_slides = slides.length;
const n_slidesCloned = 0;
let slideWidth = slides[0].offsetWidth;
let spaceBtwSlides = Number(window.getComputedStyle(slideWrapper).getPropertyValue('grid-column-gap').slice(0, -2)); // drop px at the end
function index_slideCurrent() {
  return Math.round(slideWrapper.scrollLeft / (slideWidth + spaceBtwSlides) - n_slidesCloned);
}
Enter fullscreen mode Exit fullscreen mode

The first one n_slides is simply the number of slides in the carousel. Rather than hardcoding a number, it’s set to be slides.length so that we won’t need to change any code when a new slide is added to the carousel.

The second one n_slidesCloned will play a crucial role when we make the carousel infinitely-scrolling. For the time being, however, it’s set to be zero.

The third one, slideWidth, is the width of each slide. It assumes all the slides are of equal width. It is defined with let, because the slide width can change when the user resizes the browser window width (see Section 3.7 below).

The fourth one, spaceBtwSlides, is the width of whitespace between the pair of slides next to each other. Again, it is defined with let as the width of whitespace can change in response to browser window resizing (see Section 3.7 below).

Finally, index_slideCurrent() computes the index of the slide currently shown from how much the slide wrapper is scrolled (slideWrapper.scrollLeft). We use this value to choose which navigation dot gets the .is-active class and thus changes its color to blue.

Next, let’s attach a click event handler to each navigation dot:

// Nav dot click handler
function goto(index) {
  slideWrapper.scrollTo((slideWidth + spaceBtwSlides) * (index + n_slidesCloned), 0);
}
for (let i = 0; i < n_slides; i++) {
  navdots[i].addEventListener('click', () => goto(i));
}
Enter fullscreen mode Exit fullscreen mode

Now clicking each navigation dot will reveal its corresponding slide.

3.5 Smooth scrolling

When the navdot click handler executes the scrollTo() method, the slides scroll abruptly. However, we want to create an illusion of the sliding movement so that the user will perceive the carousel as scrolling horizontally.

For this purpose, the CSS property scroll-behavior needs to be set as smooth:

.carousel__slides.smooth-scroll {
  scroll-behavior: smooth;
}
Enter fullscreen mode Exit fullscreen mode

Here, I apply the smooth scrolling only when the slide wrapper gets the .smooth-scroll class, which is done at the end of the JS script at the time of page load:

// Initialization
slideWrapper.classList.add('smooth-scroll');
Enter fullscreen mode Exit fullscreen mode

This way, we are able to turn the smooth scrolling on and off programatically for the purpose of making the carousel infinitely-scrolling. See Section 4.2 below for the detail.

3.6 JS for dynamically styling navigation dots

We start with a helper function that applies the .is-active class to a navigation dot with the given index:

// Marking the nav dot for the current slide
function markNavdot(index) {
  navdots[index].classList.add('is-active');
  navdots[index].setAttribute('aria-disabled', 'true');
}
Enter fullscreen mode Exit fullscreen mode

If you are unsure of why we also need to toggle the value of the aria-disabled attribute, go back to Section 1.5 above.

We then create another helper function that runs markNavdot() for the one corresponding to the currently shown slide:

// Updating the marked nav dot
function updateNavdot() {
  const c = index_slideCurrent();
  if (c < 0 || c >= n_slides) return;
  markNavdot(c);
}
Enter fullscreen mode Exit fullscreen mode

The second line is unnecessary at this moment, but it plays a crucial role when we make the carousel infinitely-scrolling by duplicating the first slide and placing it to the right of the last one (so c >= n_slides applies when it is currently shown) or duplicating the last slide and placing it to the left of the first one (so c < 0 applies when it is currently shown). When these duplicate slides are shown, we do not want to update navigation dots because we immediately swap them with the original ones. See Section 4 for detail.

We then use a scroll event handler to execute the updateNavdot():

slideWrapper.addEventListener('scroll', () => {
  // reset
  navdots.forEach(navdot => {
    navdot.classList.remove('is-active');
    navdot.setAttribute('aria-disabled', 'false');
  });
  // mark the navdot
  updateNavdot();
});
Enter fullscreen mode Exit fullscreen mode

Every time the user scrolls the carousel, the scroll event fires, removing the is-active class from all the navigation dots and marking the one that corresponds to the current slide.

Finally, we mark the first navigation dot upon the page loading:

// Initialization
markNavdot(0); // ADDED
slideWrapper.classList.add('smooth-scroll');
Enter fullscreen mode Exit fullscreen mode

Here is the entire JS code added in this subsection:

// Marking the nav dot for the current slide
function markNavdot(index) {
  navdots[index].classList.add('is-active');
  navdots[index].setAttribute('aria-disabled', 'true');
}
// Updating the marked nav dot
function updateNavdot() {
  const c = index_slideCurrent();
  if (c < 0 || c >= n_slides) return;
  markNavdot(c);
}
slideWrapper.addEventListener('scroll', () => {
  // reset
  navdots.forEach(navdot => {
    navdot.classList.remove('is-active');
    navdot.setAttribute('aria-disabled', 'false');
  });
  // mark the navdot
  updateNavdot();
});

// Initialization
markNavdot(0); // ADDED
slideWrapper.classList.add('smooth-scroll');
Enter fullscreen mode Exit fullscreen mode

3.7 Responsive design

The above code can cause trouble if the carousel slide width and its spacing changes depending on the window width. Desktop users may frequently change the browser window size. So we need to set the resize event handler to keep slideWidth and spaceBtwSlides updated:

window.addEventListener('resize', () => {
  // update parameters
  slideWidth = slides[0].offsetWidth;
  spaceBtwSlides = Number(window.getComputedStyle(slideWrapper).getPropertyValue('grid-column-gap').slice(0, -2)); // drop px at the end and conver the string into number
});
Enter fullscreen mode Exit fullscreen mode

At this stage, the standard carousel is up and running.

Now we are ready to make the carousel infinitely-scrolling.

4. JS for infinite-scrolling

An infinite-scrolling carousel is rather easy to implement if we forget about user interactions. Both Oliver (2017) and Pared (2021) show how to use CSS animation to make a carousel infinite-scrolling.

On the other hand, Buljan (2022) uses CSS transition instead, which allows him to add user interactions via navigation dots and next/prev buttons. But swiping gesture is disabled because the carousel "slides" by the translateX() CSS function.

But his approach enlightens me: to create an illusion of infinite-scrolling to the user, we need to duplicate the first slide to the right of the last one and then instantly "scroll" backward to the original first slide.

This section describes how I’ve manage to achieve this illusion for a swipeable carousel. Maybe there is a better way, though. Post a comment if you know something better.

4.1 Duplicating slides

To duplicate HTML elements, we can use the .cloneNode(true) method:

const firstSlideClone = slides[0].cloneNode(true);
firstSlideClone.setAttribute('aria-hidden', 'true');
slideWrapper.append(firstSlideClone);
Enter fullscreen mode Exit fullscreen mode

The above code attaches the cloned first slide as the last in the series of slides. But this cloned slide serves no purpose to the screen reader users. So I add aria-hidden: true to it.

Likewise, we can attach the cloned last slide as the first in the series of slides:

const lastSlideClone = slides[n_slides - 1].cloneNode(true);
lastSlideClone.setAttribute('aria-hidden', 'true');
slideWrapper.prepend(lastSlideClone); 
Enter fullscreen mode Exit fullscreen mode

We can repeat this to clone the second slide (and the second-to-last slide) and so on, if you need to show multiple slides in a row for wide screen devices.

Now we should replace the paremeter n_slidesCloned:

const n_slidesCloned = 1; /* revised */
Enter fullscreen mode Exit fullscreen mode

This way, the correct navigation dot gets marked (see Section 3.4 above).

4.2 Instantly scrolling back/forward to the original

Now we want to instantly scroll the duplicated slide with the original one in a way the user won’t notice it. This is tricky, however, because it conflicts with smooth scrolling. We need to disable it when we scroll back/forward to the original slide. And I've learned hard that the CSS smooth scrolling takes time to be disabled.

Remember that the .smooth-scroll class needs to be applied to the slide wrapper to enable smooth scrolling (see Section 3.5 above). So the helper function to instantly scroll back to the first slide takes the following shape:

function rewind() {
  slideWrapper.classList.remove('smooth-scroll');
  setTimeout(() => { // wait for smooth scroll to be disabled
    slideWrapper.scrollTo((slideWidth + spaceBtwSlides) * n_slidesCloned, 0);
    slideWrapper.classList.add('smooth-scroll');
  }, 100);
}
Enter fullscreen mode Exit fullscreen mode

I find the delay of 100ms is enough for smooth scrolling to be disabled.

Similarly, here is the function to instantlly scroll forward to the last slide:

function forward() {
  slideWrapper.classList.remove('smooth-scroll');
  setTimeout(() => { // wait for smooth scroll to be disabled
    slideWrapper.scrollTo((slideWidth + spaceBtwSlides) * (n_slides - 1 + n_slidesCloned), 0);
    slideWrapper.classList.add('smooth-scroll');
  }, 100);
}
Enter fullscreen mode Exit fullscreen mode

You might wonder why I don’t use scrollBy() or scrollIntoView() instead of scrollTo(). I tried both and found they were unreliable. The scrollBy(), when combined with CSS Scroll Snap, behaves unreliably: Safari fails to snap the slide at the center for some reason. The scrollIntoView() is not reliable for horizontal scrolling, as commented by Werner (2019).

Finally, we execute these helper functions when the cloned last/fist slide fully appears after the carousel is scrolled. For this purpose, we revise the scroll event handler (defined in Section 3.6 above) as follows:

// Handle scroll events
let scrollTimer; // ADDED
slideWrapper.addEventListener('scroll', () => {
  navdots.forEach(navdot => {
    navdot.classList.remove('is-active');
    navdot.setAttribute('aria-disabled', 'false');
  });

  // REVISED FROM HERE
  if (scrollTimer) clearTimeout(scrollTimer); // to cancel if scroll continues
  scrollTimer = setTimeout(() => {
    if (slideWrapper.scrollLeft < (slideWidth + spaceBtwSlides) * (n_slidesCloned - 1 / 2)) {
      forward();
    }
    if (slideWrapper.scrollLeft > (slideWidth + spaceBtwSlides) * ((n_slides - 1 + n_slidesCloned) + 1/2)) {
      rewind();
    }
  }, 100);
  // REVISED UNTIL HERE

  updateNavdot();
});
Enter fullscreen mode Exit fullscreen mode

First, we use setTimeout() and clearTimeout() so that we won’t run the code for the instant scrolling while the user keeps scrolling the carousel.

Then, when the carousel is scrolled forward to reveal about half of the cloned first slide, the rewind() gets executed. Similarly, when the carousel is scrolled backward to reveal about half of the cloned last slide, the forward() gets executed.

I use half as the threshold because, for some reason, the scrollLeft value does not necessarily reflect the theoretical value based on the number of slides and its spacing. For example, when slideWidth is 312 and spaceBtwSlides is 20, the scrollLeft value may be 331.5 when the carousel hides one slide to the left. Buffering by half of the slide width prevents unexpected behavior from happening due to scrollLeft values.

There is one more reason for using _half_ as the threshold. Even when the user stops scrolling, CSS Scroll Snap makes the carousel continue scrolling until the slide takes its center position. Only then the above scroll event handler implements the code inside the setTimeout(). To activate this CSS Scroll Snap behavior, revealing at least half of the next element is enough.

This way, the rewind() and forward() functions get executed to swap the cloned slide with the original one without the user noticing it, creating an illusion of infinite scrolling.

4.3 Initial slide

Finally, we show the first slide, not the cloned last slide, upon page load:

// Initialization
goto(0); // ADDED
markNavdot(0);
slideWrapper.classList.add('smooth-scroll');
Enter fullscreen mode Exit fullscreen mode

Note that goto(0) needs to be executed before adding the .smooth-scroll class to active smooth scrolling. Otherwise, the user may see the carousel scrolling from the cloned last slide to the first slide.

4.4 Why not jump-link navigation dots?

In Section 1.5 above, I wrote marking up navigation dots as <a> elements is “not convenient to create infinite-scrolling carousels”. This is because, as I explained in this section, we need to duplicate the first and last slides to create the illusion of infinite-scrolling. Then, the id attribute value for each slide will also be duplicated, causing jump-links to fail.

Maybe there is a workaround. Let me know if you have an idea, by posting a comment to this article.


Now we have the carousel infinite-scrolling. The last challenge is to make our carousel autoplay.

5. JS for autoplay

5.1 Moving to next slide

First of all, we need a function to execute for scrolling the carousel to the next slide:

function next() {
  goto(index_slideCurrent() + 1);
}
Enter fullscreen mode Exit fullscreen mode

Then, by executing this function repeatedly, we can implement the autoplaying feaeture.

5.2 Starting to autoplay

To do so, we create a function to start autoplaying the carousel with the use of setInterval():

const pause = 2500;
let itv;
function play() {
  // early return if the user prefers reduced motion
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    return;
  }
  clearInterval(itv);
  slideWrapper.setAttribute("aria-live", "off");
  itv = setInterval(next, pause);
}
Enter fullscreen mode Exit fullscreen mode

The pause variable sets the interval: in this example, the carousel automatically switches to the next slide every 2.5 seconds.

Then we define the play() function which starts autoplaying the carousel. It first checks the user preference on reduced motion. If the user disables animation, this function returns so that autoplay won’t start.

Then, set the aria-live attribute to be off so that screen readers won’t read out the carousel content every time the slide changes (see Section 1.2).

Finally, execute the next() function (defined in section 5.1 above) every 2.5 seconds. This setInterval() function is stored as the itv variable so that it will first get cleared with clearInterval() every time the play() runs. Otherwise, multiple setInterval() functions may run at the same time, changing the slide more frequently than every 2.5 seconds.


The interval of 2.5 seconds is probably too short in most cases. This short interval can make sense if the whole purpose of the carousel is to stress the presence of several examples of something in a short period of time with the detail of each unimportant.

For most cases, however, a longer interval is desirable to allow the user to understand what each slide is about. Friedman (2022) recommends five to seven seconds.

5.3 Stopping autoplay

It is very important to stop autoplay whenever the user interacts with the carousel:

It goes without saying that auto-rotation should stop entirely when a user interacts with a slice of the carousel, be it by hovering, focusing, or tapping through available options. Interrupting the exploration of selected items is a safe way to drive users away from the carousel for good. — Friedman (2022)

So we first define the helper function that stops autoplay:

function stop() {
  clearInterval(itv);
  slideWrapper.setAttribute("aria-live", "polite");
}
Enter fullscreen mode Exit fullscreen mode

It clears the setInterval() function that implements autoplay. Then it sets aria-live to be polite so that screen readers will announce content changes when the user presses a navigation dot, for example (see Section 1.2 above).

5.4 Intersection Observer

The first instance to start autoplay is when the carousel fully enters into the user’s viewport. It doesn’t make sense to autoplay the carousel when it’s not shown entirely to the user.

For the same reason, the autoplay should stop when part of the carousel goes outside the viewport.

To implement this feature, we use the mighty Intersection Observer:

const observer = new IntersectionObserver(callback, {threshold: 0.99});
function callback(entries, observer) {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      play();
    } else {
      stop();
    }
  })
}

observer.observe(carouselContainer);
Enter fullscreen mode Exit fullscreen mode

I use the threshold of 0.99. When the threshhold is 1, Edge fails to implement Intersection Observer as reported by Мария 2019. When the threshold is 0.999, MacOS Safari fails to start autoplaying when the entire carousel is shown upon page load, because it computes the intersectionRatio property (i.e. the fraction of the element visible inside the viewport) to be something like 0.9988713264465332. Indeed, Almand (2019) reports that “the number will be somewhere between 0.99 and 1.” So it is safe to set the threshold to be 0.99.

Also one important thing to remember is that the callback function will be executed when observer.observe() gets executed in the script (snewcomer 2018). This means that if the callback function is simply play(), the autoplay starts as soon as the page is loaded, defeating the whole purpose of using the Intersection Observer. Make sure you check whether the carousel is inside the viewport with entry.isIntersecting.

5.5 Togging autoplay for mouse users

Next, we want to disable autoplay whenever mouse users hover the cursor over the carousel. No one is happy if the carousel moves when he/she tries to click a button within the slide or one of those navigation dots.

For this purpose, we use pointerenter and pointerleave event listeners and attach them to the carousel container:

carouselContainer.addEventListener("pointerenter", () => stop());
carouselContainer.addEventListener("pointerleave", () => play());
Enter fullscreen mode Exit fullscreen mode

5.6 Togging autoplay for keyboard users

Next, keyboard users do not want the carousel to move when they press the Tab key to focus on interactive elements inside it such as navigation dots.

For this purpose, we use focus and blur event listeners:

carouselContainer.addEventListener("focus", () => stop(), true);
carouselContainer.addEventListener("blur", () => {
  if (carouselContainer.matches(":hover")) return;
  play();
}, true);
Enter fullscreen mode Exit fullscreen mode

The option true is necessary to catch the focus/blur event fired by any element inside the carousel container.

When the blur event fires, the above code first checks if the cursor is inside the carousel. If so, it won’t restart autoplay. This is for those mouse users who may use the Tab key to interact with buttons (maybe because they minimize the use of a mouse due to wrist injury).

The use of .matches(":hover") is a clever technique, suggested by Jacob (2019), to check whether the cursor is inside an element

5.7 Togging autoplay for touch device users

For smartphone and tablet users, it will be annoying to activate autoplay after swiping the carousel to look at a new slide.

In my humble opinion, the best UX in this case will be achieved by the following:

carouselContainer.addEventListener("touchstart", () => stop());
Enter fullscreen mode Exit fullscreen mode

The touchstart event fires whenever the user touches the carousel, in which case autoplay stops. Unlike the pointerleave and blur events, however, I don’t restart autoplay when the touchend event fires.


The whole JS script for autoplay is as follows:

// Autoplay
function next() {
  goto(index_slideCurrent() + 1);
}
const pause = 2500;
let itv;
function play() {
  // early return if the user prefers reduced motion
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    return;
  }
  clearInterval(itv);
  slideWrapper.setAttribute("aria-live", "off");
  itv = setInterval(next, pause);
}
function stop() {
  clearInterval(itv);
  slideWrapper.setAttribute("aria-live", "polite");
}
// Start autoplay when the carousel is fully shown
const observer = new IntersectionObserver(callback, {threshold: 0.99});
observer.observe(carouselContainer);
function callback(entries, observer) {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      play();
    } else {
      stop();
    }
  })
}
// for mouse users
carouselContainer.addEventListener("pointerenter", () => stop());
carouselContainer.addEventListener("pointerleave", () => play());
// for keyboard users
carouselContainer.addEventListener("focus", () => stop(), true);
carouselContainer.addEventListener("blur", () => {
  if (carouselContainer.matches(":hover")) return;
  play();
}, true);
// for touch device users
carouselContainer.addEventListener("touchstart", () => stop());
Enter fullscreen mode Exit fullscreen mode

5.8 Toggling autoplay for window resizing

There is one more thing to consider. We want to stop autoplay when desktop users resize the browser window size. This will prevent the carousel from sliding by the distance based on the previous window size.

So we revise the resize event listener (defined in Section 3.7 above) as follows:

let resizeTimer; // REVISED
window.addEventListener('resize', () => {
  slideWidth = slides[0].offsetWidth;
  spaceBtwSlides = Number(window.getComputedStyle(slideWrapper).getPropertyValue('grid-column-gap').slice(0, -2)); // drop px at the end

  // REVISED FROM HERE
  if(resizeTimer) clearTimeout(resizeTimer);
  stop();
  resizeTimer = setTimeout(()=>{
    play();
  }, 400);
  // REVISED UNTIL HERE
});
Enter fullscreen mode Exit fullscreen mode

Similarly to the scroll event listener described above, with the use of setTimeout() and clearTimeout(), we wait to implement play() until the user stops resizing the window.


That's all. Now you have a carousel that is accessible, swipeable, infinite-scrolling, and autoplaying!

Last words

Perhaps the carousel that you need to build from scratch is not exactly the same as the one built above. However, I hope this article (along with many cited articles listed below) will open the door for you to build your own carousel from scratch!

Changelog

Aug 14, 2024 (v1.0.1): Revise TL;DR to indicate that Section 3 is also about making a carousel accessible.

References

Almand, Travis (2019) “An Explanation of How the Intersection Observer Watches”, CSS-Tricks, Sep 24, 2019.

Buljan, Roko C. (2022) “Infinite JavaScript Carousel”, Stack Overflow, Jul 15, 2022.

Can I Use (2024) “CSS property: scrollbar-width”, Can I Use?, acccessed on Jul 21, 2024.

Coyier, Chris (2019) “Real Simple Slider”, CodePen, May 7, 2019.

Deque Systems (undated) “Carousel (based on a tabpanel)”, Deque University, undated.

Friedman, Vitaly (2022) “Usability Guidelines For Better Carousels UX”, Smashing Magazine, Apr 13, 2022.

GrahamTheDev 2020 “They are indeed very similar, there is one key distinction...”, Stack Overflow, Jul 21, 2020.

Hempenius, Katie (2021) “Best practices for carousels”, web.dev, Jan 26, 2021.

Jacob (2019) “In modern browsers you can just do element.matches(':hover')”, Stack Overflow, Aug 12, 2019.

John, Theodore (2023) “Scrollbar Styling in Chrome, Firefox, and Safari — Customizing Browser Elements”, Medium, Jul 4, 2023.

Мария (2019) “Intersection Observer doesn't work in Edge”, Stack Overflow, May 29, 2019.

MDN contributors (2024a) “ARIA: group role”, MDN Web Docs, last updated Apr 17, 2024.

MDN contributors (2024b) “WAI-ARIA basics”, MDN Web Docs, last updated Jan 1, 2024.

Oliver, Jack (2017) “Infinite autoplay carousel”, CodePen, Nov 3, 2017.

Pared, Warren (2021) “Making an infinite CSS carousel”, Dev Community, Apr 12, 2021.

PowerMapper (2023) “WAI-ARIA Screen reader compatibility”, PowerMapper Screen Reader Tests, Dec 12, 2023.

Rifki, Nada (2020) “How to use CSS Scroll Snap”, LogRocket, Mar 9, 2020.

Shadeed, Ahmad (2020) “Styling The Good Ol' Button Element”, ishadeed.com, Feb 19, 2020.

snewcomer (2018) “That is the default behaviour. When you instantiate an instance of the IntersectionObserver, the callback will be fired...”, Stack Overflow, Nov 20, 2018.

van der Schee, Joost (2021) “Carousel with CSS scroll snap”, CodePen, Apr 17, 2021.

W3C Web Accessibility Initiative (2024a) “Carousel (Slide Show or Image Rotator) Pattern”, ARIA Authoring Practices Guide, last updated on Feb 13, 2024.

W3C Web Accessibility Initiative (2024b) “Auto-Rotating Image Carousel Example with Buttons for Slide Control”, ARIA Authoring Practices Guide, last updated on Feb 13, 2024.

W3C Web Accessibility Initiative (2024c) “Landmark Regions”, ARIA Authoring Practices Guide, last updated Feb 13, 2024.

W3C Web Accessibility Initiative (2024d) “Auto-Rotating Image Carousel with Tabs for Slide Control Example”, ARIA Authoring Practices Guide, last updated Feb 13, 2024.

Webb, Jason (2021) “When testing the "tabbed" pattern with live low-vision, blind, and deafblind users at Accessible360...”, Dev Community, Oct 2, 2021.

Weckenmann, Sonja (2023) “A Step-By-Step Guide To Building Accessible Carousels”, Smashing Magazine, Feb 17, 2023.

Werner, Thomas (2019) “Unfortunately scrollIntoView doesn’t seem to behave consistently across platforms...”, Dev Community, Apr 12, 2019.

Top comments (5)

Collapse
 
dandoeswebdev profile image
Dan

Thank you for the fantastic post. I can finally finish up on one of my carousels (Adding the infinite scrolling). One question, though: will the carousel still work if I omit the role, aria-roledescription, and other aria attributes in my HTML code?

Collapse
 
masakudamatsu profile image
Masa Kudamatsu • Edited

Why do you need to omit those ARIA attributes? Screen reader users will fail to understand there is a carousel if you omit them.

Thank you for your compliment! I'm also glad that my article helped you create carousels!

Collapse
 
dandoeswebdev profile image
Dan

I wanted to omit them cause I'm not really planning on publishing the work online. It's just for better understanding the CSS scroll-snap properties and the Javascript Scroll to methods, to be specific.

Collapse
 
abdullaharik profile image
Abdullah Arık

This is awesome. Good, detailed post. Thanks

Collapse
 
iftekhar_ahmedeatherea profile image
Iftekhar Ahmed Eather (Eather)

Thank you so much for a long but effective post.