Search code examples
javascripthtmlcss

Scroll markers (indicators/dots) with CSS scroll snap


I'm building a scrolling carousel using CSS scroll snap.

.carousel {
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  width: 100vw;
  white-space: nowrap;
  overflow-x: scroll;
}

.carousel > * {
  display: inline-block;
  scroll-snap-align: start;
  width: 100vw;
  height: 100vh;
}

#x {
  background-color: pink;
}
#y {
  background-color: lightcyan;
}
#z {
  background-color: lightgray;
}
<div class="carousel">
<div id="x">x</div>
<div id="y">y</div>
<div id="z">z</div>
</div>

It works pretty well; if you scroll the carousel with the trackpad or with a finger, it snaps to each item.

What I'd like to add to this are "scroll markers." e.g. in Bootstrap, these are little dots or lines indicating that there are multiple things in the carousel, one scroll marker per item in the carousel, with the current item highlighted. Clicking on an indicator will typically scroll you to the specified item.

bootstrap screenshot with carousel scroll markers

Do I need JavaScript for this? (I assume so.) How should I detect which item is currently visible?


Solution

  • EDIT: Some day, you'll be able to do this in pure CSS with CSS scroll markers.

    In the meantime, the way to handle this in today's browsers is with IntersectionObserver. In the below example, the IntersectionObserver fires its callback whenever one of the child elements crosses the 50% threshold, making it the element with the largest intersectionRatio. When this happens, we re-render the indicator based on the index of the currently selected item.

    Bonus tip: You can use scroll-behavior: smooth to animate the scrollIntoView call.

    var carousel = document.querySelector('.carousel');
    var indicator = document.querySelector('#indicator');
    var elements = document.querySelectorAll('.carousel > *');
    var currentIndex = 0;
    
    function renderIndicator() {
      // this is just an example indicator; you can probably do better
      indicator.innerHTML = '';
      for (var i = 0; i < elements.length; i++) {
        var button = document.createElement('button');
        button.innerHTML = (i === currentIndex ? '\u2022' : '\u25e6');
        (function(i) {
          button.onclick = function() {
            elements[i].scrollIntoView();
          }
        })(i);
        indicator.appendChild(button);
      }
    }
    
    var observer = new IntersectionObserver(function(entries, observer) {
      // find the entry with the largest intersection ratio
      var activated = entries.reduce(function (max, entry) {
        return (entry.intersectionRatio > max.intersectionRatio) ? entry : max;
      });
      if (activated.intersectionRatio > 0) {
        currentIndex = elementIndices[activated.target.getAttribute("id")];
        renderIndicator();
      }
    }, {
      root:carousel, threshold:0.5
    });
    var elementIndices = {};
    for (var i = 0; i < elements.length; i++) {
      elementIndices[elements[i].getAttribute("id")] = i;
      observer.observe(elements[i]);
    }
    .carousel {
      scroll-snap-type: x mandatory;
      -webkit-overflow-scrolling: touch;
      width: 100vw;
      white-space: nowrap;
      overflow-x: scroll;
      scroll-behavior: smooth
    }
    
    .carousel > * {
      display: inline-block;
      scroll-snap-align: start;
      width: 100vw;
      height: 80vh;
    }
    
    #x {
      background-color: pink;
    }
    #y {
      background-color: lightcyan;
    }
    #z {
      background-color: lightgray;
    }
    <div class="carousel">
    <div id="x">x</div>
    <div id="y">y</div>
    <div id="z">z</div>
    </div>
    <div id="indicator">• ◦ ◦</div>