ActiveList CSS snap carousel tutorial

1. Introduction

In this tutorial we are going to progressively enhance a CSS snap based carousel.

In modern CSS it is possible to create "snap" scroll experiences using the scroll-snap-type property. This allows you to create a scrollable area, which when the user scrolls though it, makes the content snaps to an axis. This means that when the user stops scrolling, the browser will make the scrollable area always stop at a snap point, and never halfway.

With CSS snap points you can create a carousel that does not require any JavaScript to work. Lets see how we can use the ActiveList to enhance the experience.

To get started with the tutorial open and fork this StackBlitz. By using the sandbox you do not have to worry to much about setting up an entire development environment.

2. Files overview

In the sandbox you will see a couple of files:

  1. carousel.css it contains all CSS needed for the carousel.
  2. index.html contains the HTML for the CSS snap carousel. It also includes "uiloos" from the UNPKG cdn.
  3. carousel.js in this empty file we are going to bring the carousel to life.

3. Goals

We want our carousel to do the following things:

  1. It should auto play and move through the slides automatically.
  2. When the user interacts with the carousel the auto play should stop.
  3. Show a progress indicator so the user an see when the next slide is going to appear.
  4. Visually provide a "tell" that the carousel can be swiped through.

4. Setting up autoPlay

First play around a little with the carousel at its current state. See that without any JavaScript it just works through the magic off CSS snap.

In the "index.html" file note that the "carousel" is a ul HTML element, where each li is a slide. What we are going to do is scroll to each slide, when it is active.

Open up the "carousel.js" file and add the following:

// Get a reference to the carousel element
const carouselEl = document.getElementById("carousel");

// Array.from is needed becaue querySelectorAll
// returns a NodeList and not an array.
const slides = Array.from(
  carouselEl.querySelectorAll(".slide")
);

const carousel = new window.uiloosActiveList.ActiveList(
  {
    // The slides will be the contents of the ActiveList
    contents: slides
    activeIndexes: [0],

    autoPlay: {
      // Each slide should take 5000 milliseconds, note
      // that you can also provide a function so each
      // slide has a unique duration.
      duration: 5000
    }
  },
  new window.uiloosActiveList.createActiveListSubscriber({
    onActivated(event, carousel) {
      // The index of the slide times the width 
      // of the carousel is the position on the 
      // horizontal axis.
      const position = carousel.lastActivatedIndex * carouselEl.clientWidth;

      // Slide the carousel <ul> to the desired slide.
      carouselEl.scrollTo({
        top: 0,
        left: position,
        behavior: "smooth"
      });
    }
  })
);

This creates a carousel which slides automatically from the first slide over to the last slide.

What the code does is initialize an ActiveList which has contents set to the .slide elements. By setting autoPlay up with a duration of 5000, every 5 seconds a new slide will be scrolled to.

Whenever a slide becomes activated, our onActivated method is called with an ActiveListActivatedEvent event. Now all we need to do is slide the carousel ul element to the correct position.

By scrolling with behavior set to smooth the browser will animate the scrolling.

5. Making it circular

Our animation stops whenever it is at the final slide. Perhaps it is better if it would then repeats the carousel at the first slide, lets try and make this happen.

In the "carousel.js" alter the "config" and set isCircular to true:

const carousel = new window.uiloosActiveList.ActiveList(
  {
    // Abbreviated for brevity, rest of 
    // config is still the same.

    // Make the last slide go to the first slice and vice versa
    isCircular: true
  };

Now what you should see happening is that after the final slide it goes back to the first slide. It will repeat this cycle forever.

It however feels a little of putting, the animation from last- to first slide takes a lot of time.

What if we could give it the illusion of being infinity scrollable? What that means is that when it is at the last slide, it should scroll "right" back to the first slide. This way the carousel always moves to the right, and never runs out of slides.

In order for this to work we need to perform a magic trick: we need to take the previous active slide and move it to the back, so it becomes the last slide.

Lets change the onActivated to make this work:

onActivated(event, carousel) {
  // Slide the carousel <ul> to the desired slide.
  carouselEl.scrollTo({
    top: 0,
    left: carousel.lastActivatedIndex * carouselEl.clientWidth,
    behavior: "smooth"
  });

  // Now this is the magic trick: we take the previous
  // slide and move it to the last position. This creates
  // an infinitely scrolling snap carousel.
  // Do perform the move after a timeout however, so the move
  // does not affect the smooth scroll of the next slide.
  window.setTimeout(() => {
    // When you append an element which is already a child
    // it will get moved, so there is no need to remove the
    // element first.
    carouselEl.append(carousel.lastDeactivated);

    // Now also update the ActiveList itself
    carousel.lastDeactivatedContent.moveToLast();

    // Reset the scrollLeft, needed for Safari
    // and FireFox otherwise the wrong slide
    // will be shown.
    carouselEl.scrollLeft = 0;
  }, 1000);
}

Take a look at your carousel and note that it is now circular!

7. A progress indicator

It would be nice to give the user an indication of when the carousel will move to the next slide. Lets add a progress indicator.

Again in the file "carousel.js" change the following:

// Abbreviated same as before

// Get a reference to the progress element
const progressEl = document.getElementById(
  "carousel-progress"
);

const carousel = new window.uiloosActiveList.ActiveList(
  {
    // Abbreviated same config as before
  },
  new window.uiloosActiveList.createActiveListSubscriber({
    onInitialized(event, carousel) {
      // Start the progress animation
      progressEl.style.animation = 
        `progress ${carousel.autoPlay.duration}ms linear`;
    },

    onActivated(event, carousel) {
      // Abbreviated same onActivated as before, add the following 
      // below the window.setTimeout
  
      // Technically the animation needs not be reset,
      // since all slides have the same duration. But
      // if you'd change the autoPlay duration to a
      // function the progress would be wrong.
      progressEl.style.animation = `progress ${carousel.autoPlay.duration}ms linear`;

      // By removing the node and inserting it again
      // the animation is restarted.
      progressEl.remove();
      carouselEl.parentElement.insertBefore(
        progressEl, carouselEl
      );
    }
  })
);

The one bit that requires some explanation is the style.animationPlayState it is a property that allows you to pause and resume a CSS animation from within JavaScript.

Now we can see how long a slide will stay active, giving the user a sense of time.

7. Stopping on interaction

We have now enhanced the carousel with a pretty nifty auto play, but we have broken the user interaction: the auto play interferes with any user interaction.

We want to add two things:

First we want to pause the auto play on mouse hover and resume on mouse out. This way the users can view an image a little longer if they want to.

Second we want to stop the auto play as soon as the user scrolls.

There is however a big catch, how are we going to know the difference between a user scrolling and a scrollTo() call?

Here is a solution:

// Abbreviated: variables as before

// Unfortunately there is no way of knowing whether or
// not a scroll happened programmatically via "scrollIntoView"
// or by the end user.
let carouselIsScrolling = false;

const carousel = new window.uiloosActiveList.ActiveList(
  {
    // Abbreviated: same config as before
  },
  new window.uiloosActiveList.createActiveListSubscriber({
    // Abbreviated: onInitialized is same as before

    onAutoPlayPaused() {
      // Halt the animation when paused
      progressEl.style.animationPlayState = "paused";
    },

    onAutoPlayPlaying() {
      // Resume animation when playing
      progressEl.style.animationPlayState = "running";
    },

    onAutoPlayStopped() {
      // Remove the progress indicator now that the user has
      // assumed full control over the carousel.
      progressEl.style.background = "white";
    },

    onActivated(event, carousel) {
      // We are now scrolling as the carousel.
      carouselIsScrolling = true;

      // Abbreviated rest is same as before
    }
  })
);

// Disable the carousel when users mouse enters the carousel
carouselEl.addEventListener("mouseenter", () => {
  carousel.pause();
});

// Enable the carousel again when users mouse 
// exits the carousel.
carouselEl.addEventListener("mouseleave", () => {
  if (!carousel.autoPlay.hasBeenStoppedBefore) {
    carousel.play();
  }
});

// When the user scrolls stop the autoplay, 
// the user now takes over.
carouselEl.addEventListener("scroll", () => {
  // In browser that do not support "onscrollend"
  // stop the carousel when not playing. This does
  // lead to a funky behavior: when the carousel
  // is scrolling and the user hovers over the
  // carousel at that moment, the carousel will
  // stop.
  if (!("onscrollend" in window) && !carousel.autoPlay.isPlaying) {
    carousel.stop();
    return;
  }

  // If the carousel is scrolling automatically no not stop.
  if (carouselIsScrolling) {
    return;
  }

  // The scroll event is also triggered by the 
  // ACTIVATED scrollIntoView and we want to ignore 
  // that. We know when the user hovers over the
  // carousel that is is "paused", so if it is playing 
  // at this moment  we know it is an carousel 
  // ACTIVATED scroll
  if (!carousel.autoPlay.isPlaying) {
    carousel.stop();
  }
});

// This is called multiple times for some reason,
// when scrolling via "scrollIntoView" so it must 
// be debounced.
let scrollEndTimerId = -1;
carouselEl.addEventListener("scrollend", () => {
  window.clearTimeout(scrollEndTimerId);

  scrollEndTimerId = window.setTimeout(() => {
    // The carousel auto scroll has come to an end.
    carouselIsScrolling = false;
  }, 500);
});

The most tricky bit understanding why we do not call play() when hasBeenStoppedBefore is false.

The reason for this is a conflict between the "scroll means stop", and "mouse out should continue" logic. You only want to continue the animation on mouse out if the user did not scroll the carousel.

The hasBeenStoppedBefore was created for this precise scenario. It keeps track whether or not the auto play was stopped at one point in time.

Now for the solution to our "who is scrolling problem", we only want to stop when the user scrolls, and not when the carousel is auto play scrolling. Unfortunately for us scrollTo does not indicate in the event, that it was called programmatically.

This is why the carouselIsScrolling and the scrollend event must exist, so we can tell if it was the user or the carousel that is scrolling.

Which is unfortunate since this bloats up the code by quite a bit. Hopefully one day the information will be part of the scroll event in future browser versions.

The carousel now pauses on hover and stops on user scroll.

8. Peek ahead

At this point we have created a very nice carousel, but lets say our designer / product owner throws us an extra requirement.

I want the user to always see part of the previous and next slides. So the user knows they can scroll and is looking at a carousel.

Lets call this carousel a peek ahead carousel.

It requires some changes to the CSS so open up 'carousel.css' and alter the .slide to:

.slide {
  min-width: calc(512px - 48px);
  scroll-snap-align: center;
}

It does work but there are a two of things we need to fix:

  1. It would be nice if the carousel started on the second slide, so the first slide is on its left. This is more visually consistent.
  2. Our move previous slide to last now breaks the carousel. It causes a weird bump because suddenly there is nothing to the left anymore, which is now visible. We should not move the previous slide but the slide before the previous slide!

Lets make the changes first and then look at the why.

In "carousel.js" change the config to:

{
  // Only change activeIndexes, and add a 
  // maxActivationLimit

  // Start at the second slide so there is content
  // on both the left and the right, if started on the
  // 0 index, we would show a slide without a previous
  // slide, which looks sloppy.
  activeIndexes: [0, 1],

  // The last activated slide is the one we are 
  // going to show. The slide that was active two 
  // slides ago we are going to move. The trick 
  // here is that while the ActiveList considers
  // three slides to be active, we only consider 
  // the last active slide active.
  maxActivationLimit: 3,
};

The trick is explained in the comments: what we do is allow three "slides" to be active at the same time. Nothing states that whatever the ActiveList considers active must actually be shown on the screen.

This can be a bit of a mind-bender even for me the author of the library.

What this does is make the active array contain a maximum of three elements. The last element is the active the current active slide, the second element the previous slide, and the first element is the "grandparent" slide or the previous slide's previous slide.

You might think, what happens if three contents become active? The answer is that this is decided by the configured maxActivationLimitBehavior. By default it is set to circular meaning it will drop the first content that was active to make space for the newly activated content.

Now to make the scroll to the second slide at initialization alter the onInitialized:

onInitialized(event, carousel) {
  // Start the progress animation
  progressEl.style.animation = `progress ${carousel.autoPlay.duration}ms linear`;

  // Since we show the "second" slide initially have to
  // scroll instantly to that slide on initialization.
  carouselEl.scrollTo({
    top: 0,
    left: carousel.lastActivatedIndex * carouselEl.clientWidth,
    behavior: "instant"
  });
},

Finally in the onActivated change the "magic trick" to:

// Now this is the magic trick: we take the previous
// slide and move it to the last position. This creates
// an infinitely scrolling snap carousel.
// But because we show a litte of the the previous
// and next slides, (peek ahead) we need actually need to move
// the slide that was shown two iterations ago.
const previousPreviousSlide = carousel.activeContents[0];

// Do perform the move after a timeout however, so the move
// does not affect the smooth scroll of the next slide.
window.setTimeout(() => {
  // When you append an element which is already a child
  // it will get moved, so there is no need to remove the
  // element first.
  carouselEl.append(previousPreviousSlide.value);

  // Now also update the ActiveList itself
  previousPreviousSlide.moveToLast();

  // Reset the scrollLeft, needed for Safari
  // and FireFox otherwise the wrong slide
  // will be shown.
  carouselEl.scrollLeft = carouselEl.clientWidth;
}, 1000);

Our work has been done, we have a nice carousel that supports peak ahead.

9. What you have learned

  1. That the subscriber receives all events that take place on the ActiveList, and that in the subscriber you must sync the DOM with what occurred.
  2. That via autoPlay we can move through the slides programmatically at an interval.
  3. We can make an ActiveList not have an end and a beginning by setting the isCircular to true.
  4. That what is active inside of an ActiveList does not necessarily have to be displayed on the screen.
  5. That via maxActivationLimit we can set the amount of items / content which can be active at a time.

10. Further reading

  1. Browse through the API for the ActiveList, see everything at your disposal.
  2. Read the API for the ActiveListContent. As it often provides the most convenient API for mutating the ActiveList.
  3. If you do not like a CSS snap based carousel make sure you check out the tutorial for the basic carousel. Just know that it does not work without JavaScript enabled.
  4. When using vanilla JavaScript you must handle DOM manipulation yourself, contrast this with the examples that use a framework.

11. Full code

For reference here is the reference implementation of a peek ahead carousel in JavaScript, and here the reference implementation of a non peak ahead carousel in JavaScript

Usage with Frameworks
Learn how to use the ActiveList in combination with frameworks such as React and Vue.