22

I am building a carousel, very minimalist, using CSS snap points. It is important for me to have CSS only options, but I'm fine with enhancing a bit with javascript (no framework).

I am trying to add previous and next buttons to scroll programmatically to the next or previous element. If javascript is disabled, buttons will be hidden and carousel still functionnal.

My issue is about how to trigger the scroll to the next snap point ?

All items have different size, and most solution I found require pixel value (like scrollBy used in the exemple). A scrollBy 40px works for page 2, but not for others since they are too big (size based on viewport).

function goPrecious() {
  document.getElementById('container').scrollBy({ 
    top: -40,
    behavior: 'smooth' 
  });
}

function goNext() {
  document.getElementById('container').scrollBy({ 
    top: 40,
    behavior: 'smooth' 
  });
}
#container {
  scroll-snap-type: y mandatory;
  overflow-y: scroll;

  border: 2px solid var(--gs0);
  border-radius: 8px;
  height: 60vh;
}

#container div {
  scroll-snap-align: start;

  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 4rem;
}
#container div:nth-child(1) {
  background: hotpink;
  color: white;
  height: 50vh;
}
#container div:nth-child(2) {
  background: azure;
  height: 40vh;
}
#container div:nth-child(3) {
  background: blanchedalmond;
  height: 60vh;
}
#container div:nth-child(4) {
  background: lightcoral;
  color: white;
  height: 40vh;
}
<div id="container">
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <div>4</div>
</div>

<button onClick="goPrecious()">previous</button>
<button onClick="goNext()">next</button>
sebastienbarbier
  • 6,542
  • 3
  • 29
  • 55
  • I created a horizontal slider that has all your requirements: https://codepen.io/joosts/pen/MWJBPgo – Mr. Hugo Nov 17 '22 at 08:12

4 Answers4

18

Nice question! I took this as a challenge.
So, I increased JavaScript for it to work dynamically. Follow my detailed solution (in the end the complete code):

First, add position: relative to the .container, because it need to be reference for scroll and height checkings inside .container.

Then, let's create 3 global auxiliary variables:

1) One to get items scroll positions (top and bottom) as arrays into an array. Example: [[0, 125], [125, 280], [280, 360]] (3 items in this case).
3) One that stores half of .container height (it will be useful later).
2) Another one to store the item index for scroll position

var carouselPositions;
var halfContainer;
var currentItem;

Now, a function called getCarouselPositions that creates the array with items positions (stored in carouselPositions) and calculates the half of .container (stored in halfContainer):

function getCarouselPositions() {
  carouselPositions = [];
  document.querySelectorAll('#container div').forEach(function(div) {
    carouselPositions.push([div.offsetTop, div.offsetTop + div.offsetHeight]); // add to array the positions information
  })
  halfContainer = document.querySelector('#container').offsetHeight/2;
}

getCarouselPositions(); // call it once

Let's replace the functions on buttons. Now, when you click on them, the same function will be called, but with "next" or "previous" argument:

<button onClick="goCarousel('previous')">previous</button>
<button onClick="goCarousel('next')">next</button>

Here is about the goCarousel function itself:

First, it creates 2 variables that store top scroll position and bottom scroll position of carousel.

Then, there are 2 conditionals to see if the current carousel position is on most top or most bottom.
If it's on top and clicked "next" button, it will go to the second item position. If it's on bottom and clicked "previous" button, it will go the previous one before the last item.

If both conditionals failed, it means the current item is not the first or the last one. So, it checks to see what is the current position, calculating using the half of the container in a loop with the array of positions to see what item is showing. Then, it combines with "previous" or "next" checking to set the correct next position for currentItem variable.

Finally, it goes to the correct position through scrollTo using currentItem new value.

Below, the complete code:

var carouselPositions;
var halfContainer;
var currentItem;

function getCarouselPositions() {
  carouselPositions = [];
  document.querySelectorAll('#container div').forEach(function(div) {
    carouselPositions.push([div.offsetTop, div.offsetTop + div.offsetHeight]); // add to array the positions information
  })
  halfContainer = document.querySelector('#container').offsetHeight/2;
}

getCarouselPositions(); // call it once

function goCarousel(direction) {
  
  var currentScrollTop = document.querySelector('#container').scrollTop;
  var currentScrollBottom = currentScrollTop + document.querySelector('#container').offsetHeight;
  
  if (currentScrollTop === 0 && direction === 'next') {
      currentItem = 1;
  } else if (currentScrollBottom === document.querySelector('#container').scrollHeight && direction === 'previous') {
      console.log('here')
      currentItem = carouselPositions.length - 2;
  } else {
      var currentMiddlePosition = currentScrollTop + halfContainer;
      for (var i = 0; i < carouselPositions.length; i++) {
        if (currentMiddlePosition > carouselPositions[i][0] && currentMiddlePosition < carouselPositions[i][1]) {
          currentItem = i;
          if (direction === 'next') {
              currentItem++;
          } else if (direction === 'previous') {
              currentItem--    
          }
        }
      }
  } 
  
  document.getElementById('container').scrollTo({
    top: carouselPositions[currentItem][0],
    behavior: 'smooth' 
  });
  
}
window.addEventListener('resize', getCarouselPositions);
#container {
  scroll-snap-type: y mandatory;
  overflow-y: scroll;
  border: 2px solid var(--gs0);
  border-radius: 8px;
  height: 60vh;
  position: relative;
}

#container div {
  scroll-snap-align: start;

  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 4rem;
}
#container div:nth-child(1) {
  background: hotpink;
  color: white;
  height: 50vh;
}
#container div:nth-child(2) {
  background: azure;
  height: 40vh;
}
#container div:nth-child(3) {
  background: blanchedalmond;
  height: 60vh;
}
#container div:nth-child(4) {
  background: lightcoral;
  color: white;
  height: 40vh;
}
<div id="container">
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <div>4</div>
</div>

<button onClick="goCarousel('previous')">previous</button>
<button onClick="goCarousel('next')">next</button>

Another good detail to add is to call getCarouselPositions function again if the window resizes:

window.addEventListener('resize', getCarouselPositions);

That's it.
That was cool to do. I hope it can help somehow.

sebastienbarbier
  • 6,542
  • 3
  • 29
  • 55
Azametzin
  • 5,223
  • 12
  • 28
  • 46
  • I have never been a big fan on storing pixel values on js since it can so easily be out of sync, but I have to say your solution works great and seams pretty solid, loving it . Thanks for spending some time on it. – sebastienbarbier Aug 18 '19 at 06:41
  • Excellent solution thank you. Converted it into an Angular project and it works perfectly. – Ben Hayward Jun 11 '20 at 21:20
  • it's great solution, but here only one problem: on ios by click slides switch without smooths animation – Ivan Frolov Jan 03 '21 at 11:36
  • safari does not supports `behavior: 'smooth' ` but here existing polyfill https://stackoverflow.com/questions/51229742/javascript-window-scroll-behavior-smooth-not-working-in-safari – Ivan Frolov Jan 03 '21 at 11:43
12

I've just done something similar recently. The idea is to use IntersectionObserver to keep track of which item is in view currently and then hook up the previous/next buttons to event handler calling Element.scrollIntoView().

Anyway, Safari does not currently support scroll behavior options. So you might want to polyfill it on demand with polyfill.app service.

let activeIndex = 0;
const container = document.querySelector("#container");
const elements = [...document.querySelectorAll("#container div")];

function handleIntersect(entries){
  const entry = entries.find(e => e.isIntersecting);
  if (entry) {
    const index = elements.findIndex(
      e => e === entry.target
    );
    activeIndex = index;
  }
}

const observer = new IntersectionObserver(handleIntersect, {
  root: container,
  rootMargin: "0px",
  threshold: 0.75
});

elements.forEach(el => {
  observer.observe(el);
});

function goPrevious() {
  if(activeIndex > 0) {
    elements[activeIndex - 1].scrollIntoView({
      behavior: 'smooth'
    })
  }
}

function goNext() {
  if(activeIndex < elements.length - 1) {
    elements[activeIndex + 1].scrollIntoView({
      behavior: 'smooth'
    })
  }
}
#container {
  scroll-snap-type: y mandatory;
  overflow-y: scroll;

  border: 2px solid var(--gs0);
  border-radius: 8px;
  height: 60vh;
}

#container div {
  scroll-snap-align: start;

  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 4rem;
}
#container div:nth-child(1) {
  background: hotpink;
  color: white;
  height: 50vh;
}
#container div:nth-child(2) {
  background: azure;
  height: 40vh;
}
#container div:nth-child(3) {
  background: blanchedalmond;
  height: 60vh;
}
#container div:nth-child(4) {
  background: lightcoral;
  color: white;
  height: 40vh;
}
<div id="container">
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <div>4</div>
</div>

<button onClick="goPrevious()">previous</button>
<button onClick="goNext()">next</button>
donysukardi
  • 179
  • 1
  • 2
  • 5
  • Very elegant solution but this doesn't work if you have multiple sliders in DOM (because the index isn't relative to each of them) – mttetc Jul 27 '20 at 18:00
  • It only works if the container's viewport shows only 1 item. For more items, the variable `activeIndex` will actually hold the index of the item that was last shown. Now, suppose you are scrolling down (by clicking "next" or scrolling/swiping or else); then `activeIndex` will be set to the last item shown; now click "previous" and nothing happens, as `activeIndex - 1` is already visible, so no `scrollIntoView` is necessary. For more than 1 item visible, maybe we need to actually keep track of each item visibility; on `goPrevious` we must `scrollIntoView` the first non-visible item. – rslemos Dec 08 '20 at 17:25
  • Oh man. This is scrolling my whole page down. – Lisa Sep 08 '22 at 22:04
  • 1
    @Lisa use `scrollTo()` or `scrollLeft` or `scrollTop` calculations as appropriate. E.g. for a horizontal slider after a thumbnail click: `slider.scrollLeft = sliderImages.find(image => image.dataset.index === thumbnail.dataset.index).offsetLeft;` – Dan Sep 13 '22 at 10:46
5

An easier approach done with react.

export const AppCarousel = props => {

  const containerRef = useRef(null);
  const carouselRef = useRef(null);


  const [state, setState] = useState({
    scroller: null,
    itemWidth: 0,
    isPrevHidden: true,
    isNextHidden: false

  })

  const next = () => {
    state.scroller.scrollBy({left: state.itemWidth * 3, top: 0, behavior: 'smooth'});

    // Hide if is the last item
    setState({...state, isNextHidden: true, isPrevHidden: false});
  }


   const prev = () => {
    state.scroller.scrollBy({left: -state.itemWidth * 3, top: 0, behavior: 'smooth'});
    setState({...state, isNextHidden: false, isPrevHidden: true});
    // Hide if is the last item
    // Show remaining
   }

  useEffect(() => {

      const items = containerRef.current.childNodes;
      const scroller = containerRef.current;
      const itemWidth = containerRef.current.firstElementChild?.clientWidth;

      setState({...state, scroller, itemWidth});

    return () => {

    }
  },[props.items])


  return (<div className="app-carousel" ref={carouselRef}>

      <div className="carousel-items shop-products products-swiper" ref={containerRef}>
          {props.children}
      </div>
      <div className="app-carousel--navigation">
        <button className="btn prev" onClick={e => prev()} hidden={state.isPrevHidden}>&lt;</button>
        <button className="btn next" onClick={e => next()} hidden={state.isNextHidden}>&gt;</button>
      </div>

  </div>)
}

Jobizzness
  • 84
  • 1
  • 4
-1

I was struggling with the too while working with a react project and came up with this solution. Here's a super basic example of the code using react and styled-components.

import React, { useState, useRef } from 'react';
import styled from 'styled-components';

const App = () => {
const ref = useRef();

const [scrollX, setScrollX] = useState(0);

const scrollSideways = (px) => {
    ref.current.scrollTo({
        top: 0,
        left: scrollX + px,
        behavior: 'smooth'
    });

    setScrollX(scrollX + px);
};

    return (
    <div>
        <List ref={ref}>
            <ListItem color="red">Card 1</ListItem>
            <ListItem color="blue">Card 2</ListItem>
            <ListItem color="green">Card 3</ListItem>
            <ListItem color="yellow">Card 4</ListItem>
        </List>
        <button onClick={() => scrollSideways(-600)}> Left </button>
        <button onClick={() => scrollSideways(600)}> Right </button>
    </div>
);
};

const List = styled.ul`
display: flex;
overflow-x: auto;
padding-inline-start: 40px;
scroll-snap-type: x mandatory;
list-style: none;
padding: 40px;
width: 700px;
`;

const ListItem = styled.li`
display: flex;
flex-shrink: 0;
scroll-snap-align: start;
background: ${(p) => p.color};
width: 600px;
margin-left: 15px;
height: 200px;
`;
BenMcL
  • 105
  • 5