In this post we’ll look at how to make a simple carousel with HTML, CSS and JavaScript. We will use good code practices, keep accessibility in mind and also consider how we can test the carousel.
The carousel will be a "moving carousel". Slides will move in from left to right, or right to left, with a transition. It won’t be an in-place carousel where a slide fades out while another one fades-in.
If you prefer a video version, here it is. It goes into much more detail than this post.
Basic functionality
We’ll start with the basic functionality. That’s the basic HTML, CSS and JavaScript.
HTML
We’ll keep the HTML fairly simple. We basically need:
- a container for the carousel
- the carousel controls
- the slides
We won’t focus very much on the HTML head or anything other than the carousel. The rest is standard stuff.
As for the actual carousel, here is some HTML we can use.
<head>
<!-- Import font-awesome somewhere in the HTML -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" integrity="sha512-iBBXm8fW90+nuLcSKlbmrPcLa0OT92xO1BIsZ+ywDWZCvqsWgccV3gFoRBv0z+8dLJgyAHIhR35VZc2oM/gI1w==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="./index.css">
</head>
<body>
<div class="carousel" data-carousel>
<div class="carousel-buttons">
<button
class="carousel-button carousel-button_previous"
data-carousel-button-previous
>
<span class="fas fa-chevron-circle-left"></span>
</button>
<button
class="carousel-button carousel-button_next"
data-carousel-button-next
>
<span class="fas fa-chevron-circle-right"></span>
</button>
</div>
<div class="slides" data-carousel-slides-container>
<div class="slide">
<!-- Anything can be here. Each slide can have any content -->
<h2>Slide 1 heading</h2>
<p>Slide 1 content
</div>
<div class="slide">
<!-- Anything can be here. Each slide can have any content -->
<h2>Slide 2 heading</h2>
<p>Slide 2 content
</div>
</div>
</div>
</body>
In the head, we are linking font awesome and also our custom styles CSS file.
In the body:
- we have an outer
div
for the entire carousel. - we have two buttons, one for "previous slide" and one for "next slide". The buttons use font-awesome icons.
- we have a
div
for the slides. Inside that, we have adiv
for each slide. The content inside each slide is irrelevant to us, it can be anything.
As for the data-
attributes, those are what we’ll use as selectors in JavaScript.
I personally prefer using data-
attributes for JavaScript because I want to separate concerns. For example, classes are standard to use for CSS. When someone tries to change the styling of the carousel in the future, they may replace the class name for a more descriptive one. They may also change some CSS modifier classes or something. I don’t want them to be paranoid that if they change the CSS they may break the JavaScript, or the automated tests, or the asynchronous content insertions, or anything else. I want them to feel safe when working with the CSS.
This means, that I do not use classes to select elements with JavaScript.
An exception to this is if you use classes with a prefix such as js-
. E.g. <div class="js-carousel"></div>
, which are exclusively for JavaScript use. That achieves the same result.
But my preference is to use data-
attributes. That’s what data-carousel
and the others are for.
CSS
Our CSS:
- is going to have the basic styling for our carousel
- is going to have the mechanism for changing the slides
The way our carousel will work is by having all slides horizontally next to each other. However, only one slide will show at a time. That’s because every slide, except the one that’s visible, will be overflowing outside of the top-level carousel div
. That div
will have overflow: hidden
, so nothing that’s overflowing will show.
We’ll decide which slide is currently showing with the line transform: translateX(/* something */)
. That way, we’ll translate the slides
div, so that only the correct slide is visible.
Here is the CSS.
.carousel {
--current-slide: 0;
/* we set position relative so absolute position works properly for the buttons */
position: relative;
overflow: hidden;
}
.carousel-button {
/* vertically centering the buttons */
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 1;
/* basic styling */
padding: 0;
margin: 0.5rem;
border-radius: 50%;
background-color: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
transition: color 0.1s;
}
.carousel-button:hover {
color: rgba(0, 0, 0, 0.5);
}
.carousel-button_next {
/* The "next slide button" will be at the right */
right: 0;
}
.slides {
display: flex;
transition: transform 0.5s;
transform: translateX(calc(-100% * var(--current-slide)));
}
.slide {
flex: 0 0 100%;
}
@media screen and (min-width: 768px) {
.carousel-button {
font-size: 2rem;
margin: 1rem;
}
}
With this CSS, every div
has its default width of 100%. This means that the carousel will take the full width of its parent container. Every slide will also take up the full width of the carousel.
Controls
In the carousel-button
class, we provide some simple styling for the buttons. We’re using font-awesome icons, so we give them a font-size so they’re large and visible. We also remove some of the default button styling (things like borders and background color).
Also, we position the buttons in the middle (vertically) of the entire carousel. We do this by using the position: absolute; top: 50%; transform: translateY(-50%);
trick.
Changing slides
The trick for how the carousel actually changes slides is the CSS in .slides
and .slide
. In .slide
, we make each slide have 100% of the width of the carousel. This is done with the flex
property. In other words, one slide will take up the entire width of the carousel.
Since .slides
is display: flex;
, all of the slides will be horizontally next to each other. This means that one slide will take up the entire width of the carousel and all other slides will overflow horizontally next to it. The carousel div has overflow: hidden;
, so none of the overflowing slides will show.
At some point, using JavaScript, we’ll do something to move the .slides
div to the right or left. Since the slides will move, a different slide will be visible inside the carousel.
The declaration transform: translateX(calc(-100% * var(--current-slide)));
is our movement mechanism. Here we’re saying to move the slides container -100% (the full-width of the carousel, or the full width of a slide) to the left (the negative sign means to the left), as many times as the slide index we’re on.
For example, if we’re on slide index 0 (first slide), -100% * 0
= 0, so we don’t translate at all and the first slide is visible.
If we’re on slide index 1 (second slide), then -100% * 1
= -100%, so we translate 100% (one slide width) to the left. This means that we’re displaying the second slide.
We’ll set the --current-slide
property using JavaScript.
JavaScript
Our JavaScript needs to:
- handle events for the two buttons (switch to previous slide and next slide)
- work independently for any number of different carousels on the page
Here is the JavaScript.
function modulo(number, mod) {
let result = number % mod;
if (result < 0) {
result += mod;
}
return result;
}
function setUpCarousel(carousel) {
function handleNext() {
currentSlide = modulo(currentSlide + 1, numSlides);
changeSlide(currentSlide);
}
function handlePrevious() {
currentSlide = modulo(currentSlide - 1, numSlides);
changeSlide(currentSlide);
}
function changeSlide(slideNumber) {
carousel.style.setProperty('--current-slide', slideNumber);
}
// get elements
const buttonPrevious = carousel.querySelector('[data-carousel-button-previous]');
const buttonNext = carousel.querySelector('[data-carousel-button-next]');
const slidesContainer = carousel.querySelector('[data-carousel-slides-container]');
// carousel state we need to remember
let currentSlide = 0;
const numSlides = slidesContainer.children.length;
// set up events
buttonPrevious.addEventListener('click', handlePrevious);
buttonNext.addEventListener('click', handleNext);
}
const carousels = document.querySelectorAll('[data-carousel]');
carousels.forEach(setUpCarousel);
This code may be appear a bit confusing because of the nested functions. If you’re not used to this syntax, then here is a class alternative for the setUpCarousel
function which does exactly the same thing.
class Carousel {
constructor(carousel) {
// find elements
this.carousel = carousel;
this.buttonPrevious = carousel.querySelector('[data-carousel-button-previous]');
this.buttonNext = carousel.querySelector('[data-carousel-button-next]');
this.slidesContainer = carousel.querySelector('[data-carousel-slides-container]');
// state
this.currentSlide = 0;
this.numSlides = this.slidesContainer.children.length;
// add events
this.buttonPrevious.addEventListener('click', this.handlePrevious.bind(this));
this.buttonNext.addEventListener('click', this.handleNext.bind(this));
}
handleNext() {
this.currentSlide = modulo(this.currentSlide + 1, this.numSlides);
this.carousel.style.setProperty('--current-slide', this.currentSlide);
}
handlePrevious() {
this.currentSlide = modulo(this.currentSlide - 1, this.numSlides);
this.carousel.style.setProperty('--current-slide', this.currentSlide);
}
}
const carousels = document.querySelectorAll('[data-carousel]');
carousels.forEach(carousel => new Carousel(carousel));
Basically, we’re holding some state, the currentSlide
and the numSlides
variables. We’re also holding references to some HTML elements, such as the carousel element, because we’ll need them when changing slides. Finally, we add event listeners to the buttons.
When the user clicks on the "next slide" button, we run the handleNext
function. The call to modulo(currentSlide, numSlides)
sets currentSlide
to the correct index for the next slide. So, if there are 5 slides, and we’re on slide index 0, it will set currentSlide
to 1. But, if we’re already on slide index 4 (the fifth and final slide), then the next slide index is 0, not 5. The modulo function takes care of the wrapping back to 0 for us.
Really, we could have used the %
(modulo) operator for this. The reason why we have the modulo
function is because %
doesn’t play well with negative numbers. -1 % 5
evaluates to -1
, rather than 4
(the index of the slide we would actually want). We created our own modulo
function to handle that case.
Finally, we set the CSS property --current-slide
to the correct number. Then, the CSS changes the visible slide by translating the slides div
appropriately.
The independence of different carousels on the page happens because we use querySelector
on the parent carousel element, not on the document
. This means that, for example, carouselElement1.querySelector([data-carousel-button-next])
, will only get the button inside that carousel element. Whereas document.querySelector('[data-carousel-button-next]')
would get the first matching element it finds on the page, rather than the target carousel.
Accessibility
At the moment, this carousel is very unfriendly to screen reader users. You’ll need to actually use a screen reader and listen to it to hear it for yourself (or watch the accessibility section of the embedded video), but basically:
- it doesn’t mention anything about the content being a carousel
- for the buttons, it just says "button" and nothing else (because the buttons don’t have text or a label)
- on "auto read", it reads through all of the content of every slide, as though it was a normal web page full of text (because we’re not telling it to only read the visible slide)
To fix those issues, we need to go to the WAI-ARIA authoring practices document. There is a section for carousels. We just go to it and follow the instructions. It’s actually not too difficult. It has step-by-step instructions for us.
In the end, our HTML looks like this:
<div
class="carousel"
role="group"
aria-roledescription="carousel"
aria-label="Student testimonials"
data-carousel
>
<div class="carousel-buttons">
<button
class="carousel-button carousel-button_previous"
aria-label="Previous slide"
data-carousel-button-previous
>
<span class="fas fa-chevron-circle-left"></span>
</button>
<button
class="carousel-button carousel-button_next"
aria-label="Next slide"
data-carousel-button-next
>
<span class="fas fa-chevron-circle-right"></span>
</button>
</div>
<div
class="slides"
aria-live="polite"
data-carousel-slides-container
>
<div
class="slide"
role="group"
aria-roledescription="slide"
aria-hidden="false"
aria-labelledby="bob"
>
<h2 id="bob">Bob</h2>
</div>
<div
class="slide"
role="group"
aria-roledescription="slide"
aria-hidden="true"
aria-labelledby="alice"
>
<h2 id="alice">Alice</h2>
</div>
</div>
</div>
A quick summary of what we did is:
- we added an
aria-role
,aria-roledescription
andaria-label
to the carouseldiv
. Now, the screen reader says something like "Student testimonials carousel", immediately indicating that this is a carousel and what content it represents. - for each button, we added an
aria-label
. Now the screen reader says something like "button previous slide", instead of just "button". (An alternative technique here would be to add "screen-reader only text". This is text that exists in the HTML but is hidden visually using particular methods.) - we added an
aria-role
andaria-roledescription
to each slide. Now the screen reader knows when it’s entering a slide or leaving a slide and it will notify the user as necessary. - we also added a label to each slide using
aria-labelledby
. This is the same asaria-label
except that you point it to some text that already exists on the page, using an HTML ID. In this case, since our label already exists on the page (the heading for each slide), we usedaria-labelledby
instead ofaria-label
. - we added
aria-hidden="true"
to the hidden slides. Now the screen reader won’t read them. - we added an
aria-live
region. Now the screen reader will re-read the content of the carousel whenever there are changes (when the user changes the slide).
There are some other aria attributes that would be useful, but I’m ignoring them for now because they’re not mentioned in the carousel part of the WAI-ARIA authoring practices. One example is aria-controls. If you want to learn more about these, it might be worth looking at the WAI-ARIA authoring practices in your own time. If you want to learn more about accessibility in general, I’ve written a learning guide in Web accessibility – Everything you need to know.
Our JavaScript needs some updates as well. Specifically, when we change slides, we need to change the aria-hidden
property to false
for the new active slide. We also need to hide the previous slide that we’re no longer looking at.
Here is some example code we can use:
function changeSlide(slideNumber) {
// change current slide visually
carousel.style.setProperty('--current-slide', slideNumber);
// handle screen reader accessibility
// here we're getting the elements for the previous slide, current slide and next slide
const previousSlideNumber = modulo(slideNumber - 1, numSlides);
const nextSlideNumber = modulo(slideNumber + 1, numSlides);
const previousSlide = slidesContainer.children[previousSlideNumber];
const currentSlideElement = slidesContainer.children[slideNumber];
const nextSlide = slidesContainer.children[nextSlideNumber];
// here, we're hiding the previous and next slides and unhiding the current slide
previousSlide.setAttribute('aria-hidden', true);
nextSlide.setAttribute('aria-hidden', true);
currentSlideElement.setAttribute('aria-hidden', false);
}
Testing
What ways are there to test something like this?
In short, I would write end-to-end tests for it. I would hesitate to write unit tests for it.
Here’s why.
An end-to-end test shows you that the entire thing works correctly.
Depending on your test framework, you could do things like:
- check that only a particular
div
(slide) is visible on the page, and the others aren’t - check that the correct
div
(slide) is visible after pressing the next / previous slide button - check that the transition for changing slides works correctly
But if you unit test, you can only check that your JavaScript works correctly.
You could do a test where you set up some HTML, then run your JavaScript and finally check that the resulting HTML is what you expect.
Or you could do something like spy on your JavaScript code, run your JavaScript and ensure your spies were called.
With the first unit test example (where you check the final HTML), the problem is that, while your tests may be passing, your carousel may not be working. For example, someone may have changed how the CSS works. They may have renamed the property --current-slide
to --index
or whatever else. Maybe they changed the entire CSS mechanism for changing the slides (for example, to improve performance).
In this case, your JavaScript will be executing without errors and the tests will be passing, but the carousel won’t be working.
The tests won’t provide confidence that your code works.
The only thing they’ll do is freeze your JavaScript implementation. This is the scenario where you’ve already checked the carousel yourself, manually, in the browser. You think "I can see that it’s working, let me write some unit tests for it that check that the JavaScript is doing X". What this does, is it prevents anyone from accidentally changing the JavaScript in the future. If they do so, the tests will fail.
But, it also makes intentional changes more difficult. Now, if you want to change the implementation in the future, you need to change your CSS, JavaScript and your 10 tests. This is one of the reasons why people dislike unit tests. They make changes to the implementation more difficult (at least with unit tests like these).
So, for these reasons, I would personally recommend writing end-to-end tests instead. Now, if you really want to prevent accidental changes in the JavaScript, that’s fine. You need to do what you need to do. It’s up to you to decide if the peace of mind is worth the downsides and the time it takes to write those tests.
As for the other scenario of unit testing, where you check that your spies were called, I just don’t see a benefit to that. With those tests, you’re not even testing that your JavaScript is doing what you think. You could break the JavaScript implementation in the future and your tests would still pass, as long as you’re calling the same functions.
But, those are just my thoughts on the matter. I’m open to differences in opinion. Please leave a comment below if you think I’m missing something.
Final notes
So that’s it. I hope that you found this article useful.
If you want a fuller view of the code, here is the code repository.
Please note that this is not meant to be production-ready. The code can be cleaned up more. It can probably be made more appropriate to what you need to use. Etc.
This is just a little tutorial to show you the general idea on how to make a simple carousel.
If you have any feedback, anything that was missed or could have been done better, or anything else, please leave a comment below.
Alright, thanks very much and see you next time.
Thanks For sharing this article.It was really informative .
My pleasure, thank you for the nice comment
Is aria-role and role the same ?I could find only role attribute in MDN docs.Is there any difference ?
Ah thanks for raising this. It’s supposed to be
role
.aria-role
isn’t a thing. I’ve updated the code above.Is there anyway you could look at my code? For some reason the buttons won’t work but I know the javascript file is connected to the html doc.
Hi Harmit,
Sorry I missed this message. If you still need help, did you have a GitHub link I can look at?
Hi Spyros. Your tutorial is very useful. I have a Razor Pages app that implements 11 responsive carousels on the same page, showing different number of images in each one, using Bootstrap 4.3.1. After I upgrade my app to Net 7.0, the carousels have stopped and now they are only showing the first slide. I think that the problem is related with button routing (scr appointing to each carousel, normally “#id”), that Razor Pages is not recognizing anymore. My question is, can I use your code to control 11 carousels at the same time? I am already implementing the tests to check it. Regards.
Hi Mario,
Thank you for your kind words.
The code should be able to control 11 carousels. However, the code is set up to control them individually. For example, for carousel 1, if you click on the button for next slide, it will only switch the slide on carousel 1 and not the others.
The code in this tutorial and the Bootstrap carousels should work in a Razor app. But, it’s important to understand how Razor works. Razor apps output HTML. You’ll need to get the Razor app to generate the correct HTML.
For this tutorial, it’s important to check that the resulting HTML has the correct data attributes, because that’s how the elements are selected. For bootstrap, you’ll need to check that the resulting HTML has the correct classes and everything else Bootstrap needs for its carousels.
You’ll also need to write your JavaScript and link it properly in the Razor app. The generated HTML should have something like
<script src="/path/to/script.js"></script>
. Bootstrap needs something like this instead:If those steps are done correctly, the resulting carousels should work. It’s just HTML and JavaScript in the end.
Note, I recommend using the Bootstrap carousels for a production application. The code in this tutorial probably needs more work and testing before it’s production-ready.
I hope that helps.
Hy Spyros. Thank you for your fast answer. My original razor carousel was based on Boostrap (carousel, carousel-inner and carousel-item classes), but implemented with HTML, CSS and Javascript, to control each carousel and change the slides. This page looks like Netflix homepage, but for images, not video and was able to handle and show more than 100 images simultaneously. And I need to control individually each carousel, like you described before. I have replaced these codes (including the classes), made some ajustments and now the page is working perfectly. Of course, I need to make more tests and add some controls, to garantee the code quality. But, at the end, Razor Pages are not friendly to integrate javascripts, specialy after Net 7.0 migration. I think that my problem is related with “#id” sent by control buttons to move the slides, that Razor is not recognizing anymore. I beleive your code is more independent and flexible than my old code. I will spend some time to improve it. Again, thank you very much and I will keep you informed.
Excellent video – and the only one I have seen which also raising the issue of good code practices.
My own 20 year old website will shortly benefit from this.
I know it might be overkill but maybe adding a working example on your page would give us a better view of the component. I gave up asking chatgpt on the endless loop part because of the % modulo issue. That’s what i was needing. A human to tell me what was wrong. Many thanks!
It’s a good point. I’ll improve the post in the future. Thank you.
Hi Spyros 👋
Hope you are doing well?
I am Murod recently I have started to learn Javascript
In order to get carousel I have researched so many youtube video blog.
Your approach really handy and useful!
Owe you say thank you so much indeed especially multiple carousel functions!
In future will contact with you if you do not mind! 👍👊🇬🇧
Thank you very much for the kind words. I’m glad it was helpful 🙂