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.
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>
For accessibility, three attributes are necessary:
-
role="group"
tells screen readers that all the child elements have related functionality to work together as one UI object (MDN contributors 2024a) -
aria-label
tells screen readers what the carousel is about. You can usearia-labelledby
instead if the carousel title is another HTML element inside the carousel container. -
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>
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>
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>
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>
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>
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%;
}
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%;
}
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;
}
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 */
}
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;
}
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 */
}
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;
}
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 */
}
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');
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);
}
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));
}
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;
}
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');
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');
}
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);
}
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();
});
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');
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');
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
});
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);
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);
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 */
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);
}
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);
}
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();
});
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');
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);
}
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);
}
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");
}
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);
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());
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);
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());
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());
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
});
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)
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?
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!
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.
This is awesome. Good, detailed post. Thanks
Thank you so much for a long but effective post.