Carousel slider tutorial with HTML, CSS and JavaScript

sargalias

Spyros Argalias

Posted on June 1, 2021

Carousel slider tutorial with HTML, CSS and JavaScript

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>
Enter fullscreen mode Exit fullscreen mode

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 a div 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:

  1. is going to have the basic styling for our carousel
  2. 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 move the .slides div to the right or left. This means that the slides will move, so 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 1, then -100% * 1 = -100%, so we translate 100% (one slide width) to the left. This means that we're displaying slide index 1 (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);
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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"
  aria-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"
      aria-role="group"
      aria-roledescription="slide"
      aria-hidden="false"
      aria-labelledby="bob"
    >
      <h2 id="bob">Bob</h2>
    </div>

    <div
      class="slide"
      aria-role="group"
      aria-roledescription="slide"
      aria-hidden="true"
      aria-labelledby="alice"
    >
      <h2 id="alice">Alice</h2>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

A quick summary of what we did is:

  • we added an aria-role, aria-roledescription and aria-label to the carousel div. 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 and aria-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 as aria-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 used aria-labelledby instead of aria-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);
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
sargalias
Spyros Argalias

Posted on June 1, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related