How to make simple slideshow using cool CSS and JS features

How to make simple slideshow using cool CSS and JS features

Hello. Today I'm making a simple slideshow using cool CSS and JS features. I'm gonna be using:

  • CSS variables
  • CSS transformations
  • CSS functions
  • CSS grid
  • JS Proxy

The HTML

<div class="slideshow">
  <div class="slides-container">
    <div class="slides">
      <img class="slide" src="https://picsum.photos/600/300?v=1" alt="">
      <img class="slide" src="https://picsum.photos/600/300?v=2" alt="">
      <img class="slide" src="https://picsum.photos/600/300?v=3" alt="">
      <img class="slide" src="https://picsum.photos/600/300?v=4" alt="">
      <img class="slide" src="https://picsum.photos/600/300?v=5" alt="">
    </div>
  </div>
  <div class="bullets">
  </div>
  <div class="buttons">
    <button type="button" class="prev">
      <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
        <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
      </svg>

    </button>
    <button type="button" class="next">
      <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
        <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
      </svg>
    </button>
  </div>
</div>

Let's break it down.

We have a div containing the whole slideshow with a class of .slideshow. This container has 3 immediate children. The first is the .slides-container which will act as the "window" behind which our slides will be showing. Then there is an empty .bullets container where we will dynamically put the bullets based on the number of slides. And the last one is the .buttons container, with the .prev and .next buttons.

Inside the .slides-container there is another container with a class of .slides which holds all our slides. This div will be moving right and left behind our "window" to reveal the active slide.

For the images I used pictures from https://picsum.photos, and for the .prev and .next buttons I used some icons from https://heroicons.com.

The CSS

First, let's define some CSS variables that we will use later.

:root {
  --max-size: 600px;  
  --size: min(var(--max-size), 100vw);
  --active: 0;
}

The --max-size is the maximum size of each slide. The --size is the calculated size of the whole slideshow. It will be 100vw for screens smaller than --max-size, and --max-size for larger screens. The --active variable holds the index of the active slide.

Now, the outer container will be a CSS grid with 1 column and 2 rows. The first row will contain the slides and the buttons, and the second row will contain the bullets.

.slideshow {
  display: grid;
  grid-template-rows: 1fr auto;
  grid-template-columns: 1fr;
  gap: 10px;
  width: var(--size);
}

Then, our "window". The .slides-container will sit in column 1 and row 1 of the parent grid. It will create a hole (a window), by having a transparent background and hiding anything overflowing.

.slides-container {
  grid-column: 1;
  grid-row: 1;
  overflow: hidden;
}

Then the .slides. This is a div containing all our slides. It has display: flex to put everything in a row, and it will move around to reveal the active slide. The interesting part here is the calculation of the container position based on the active slide and the size of the slideshow. It is -1 * size * active. Let's say the size is 600px. When the active slide is the 0th one, then it will give us 0 move on the x-axis. When the active is the 1st, then it will give -1 * 600px * 1 = -600px so it will move 600px to the left.

.slides {
  display: flex;
  transform: translateX(calc(-1 * var(--size) * var(--active)));
  transition: transform 0.3s ease;
}

Our images need to have a width equal to the --size variable, in order to be responsive. This is because their parent is really long and we can't rely on the image taking up all the available width and having a height of auto.

img {
  width: var(--size);
}

Then the buttons container. This div has to sit on top of the slides. I don't like using position: absolute so I used the grid feature of putting multiple elements in the same grid cell. So I put it in the same grid cell as the .slides-container. The problem with this is that I cannot click on the slides themselves. If the slides had a link I wouldn't be able to click on it because another div is in front of it. To tackle this problem I added pointer-events: none at the buttons container. Now I cannot click the buttons. So I add pointer-events: all at the buttons. The buttons in this container will be in the middle vertically and at the sides horizontally.

.buttons {
  z-index: 1;
  display: flex;
  justify-content: space-between; /* Horizontally at the sides */
  align-items: center; /* Vertically in the middle */
  padding: 10px;
  grid-column: 1;
  grid-row: 1;
  pointer-events: none;
}

I don't think it is essential to include the buttons styling here. I have a codepen later to showcase the slideshow demo.

The .bullets container sits in row 2 of the slideshow grid. It aligns the bullets at the center.

.bullets {
  grid-column: 1;
  grid-row: 2;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 12px;
}

Each bullet is round and the one corresponding to the active slide has different styling.

So far we have this. It doesn't work yet, but it's styled.

The javascript

First, let's keep some useful stuff in variables.

// We keep references to all needed elements.
const slideshow = document.querySelector(".slideshow");
const slidesContainer = slideshow.querySelector(".slides");
const slides = slideshow.querySelectorAll(".slide");
const bulletContainer = slideshow.querySelector(".bullets");
const prev = slideshow.querySelector(".prev");
const next = slideshow.querySelector(".next");
const length = slides.length;

Then, let's create the bullets based on the slide count. I use documentFragmet here to insert new elements into the DOM.

// Automatically create the bullets based on
// the amount of slides we have.
const fragment = document.createDocumentFragment();
slides.forEach((slide, index) => {
  const bullet = document.createElement("span");
  bullet.classList.add("bullet");
  if (!index) {
    // Set the first bullet as active
    bullet.classList.add("active");
  }
  // Add the click functionality to the bullet
  bullet.addEventListener("click", () => active.slide = index);
  fragment.appendChild(bullet);
});
bulletContainer.appendChild(fragment);

// Keep a reference to the bullets
// so we can add/remove the .active class.
const bullets = bulletContainer.querySelectorAll(".bullet");

Now let's add functionality to the buttons. When clicking on the .next button we want to increase the active index by 1, and when clicking on the .prev button we want to decrease the active index by 1. When we reach the last slide and click next, it should go back to the first. And when we are at the first and click previous it should go to the last one. However, I don't really like to put this logic into the click handlers, because by clicking .next I don't care which one is the next one. Just show me the next. So I used the magic of JavaScript Proxy. You may have noticed that in the bullet click listener I used active.slide to set the active slide index. Here is how it goes.

// We use Proxy to include some kind of reactivity.
// By just changing active.slide, the DOM gets
// changed automatically, without the need to
// make the DOM changes within the event handlers.
const active = new Proxy(
  { slide: 0 },
  {
    set(obj, prop, value) {
      if (prop == "slide") {
        // If we click "prev" while on the first slide
        // then go to the last one.
        if (value < 0) {
          value = length - 1;
        }

        // If we click "next" while on the last slide
        // then go to the first one.
        if (value >= length) {
          value = 0;
        }

        // Make the appropriate DOM changes
        slidesContainer.style.setProperty("--slide", value);
        bullets[obj[prop]].classList.remove("active");
        bullets[value].classList.add("active");
      }

      // Actually set the active.slide prop to it's new value
      obj[prop] = value;
      return true;
    }
  }
);

As stated in the MDN website

The Proxy object allows you to create an object that can be used in place of the original object, but which may redefine fundamental Object operations like getting, setting, and defining properties.

I give the Proxy a target object as the first argument, and a handler as the second argument. Into the handler there is a setter function. The first argument obj is the target object { slide: 0 }. The second argument prop is the property we are setting, in our case slide. And the third argument is the value we are setting to the property. Into the setter is where I put all the logic for calculating which slide is the one that should be showing, and making the appropriate DOM changes. We can see that the only DOM change that has to be done is to set the --active CSS variable the the appropriate active slide index, and to toggle the .active class on the corresponding bullet.

With the help of the JavaScript Proxy, I am able to separate concerns and add very simple click handlers at the .prev and .next buttons.

// Add the prev/next functionality to the buttons
prev.addEventListener("click", () => active.slide--);
next.addEventListener("click", () => active.slide++);

Here is the completed codepen.

And finally here is another codepen. It's the same as the last one, but if you press space, it goes 3D to show how the elements are placed on top of each other, and how the "window" works.

I hope you liked the techniques I used to make this slideshow. I would love to hear better alternatives. I'm always up for learning something new.

Thanks for reading this far!

Did you find this article valuable?

Support Stavros Ioannidis by becoming a sponsor. Any amount is appreciated!