Skip to content

How to sync slider indicator position with slider scroll position

I created this slider and custom indicator to show which slide you are on but I’m having trouble syncing the indicator to the slider’s scroll postion.

In the example below, the indicator’s left property is being set to whatever % the main slider has been scrolled. However, it’s not accounting for the width of the indicator itself, so it’s breaking out of the grey box containing it.

Any ideas how I can modify the moveIndicator function to have the indicator display properly? Also in this example there are two slides on screen, but this needs to work in the case of 1 or 3 slides(to test this change the <li> element width in the css to 100% or 33.3%)

class SliderComponent extends HTMLElement {
  constructor() {
    super();
    this.slider = this.querySelector('ul');
    this.slides = this.querySelectorAll('li');
    this.sliderTray = this.querySelector('.indicator-tray');
    this.sliderIndicator = this.querySelector('.indicator');
    this.prevButton = this.querySelector('button[name="previous"]');
    this.nextButton = this.querySelector('button[name="next"]');

    const resizeObserver = new ResizeObserver(entries => this.initialise());
    resizeObserver.observe(this.slider);

    this.slider.addEventListener('scroll', this.update.bind(this));
    this.prevButton.addEventListener('click', this.onButtonClick.bind(this));
    this.nextButton.addEventListener('click', this.onButtonClick.bind(this));
  }

  initialise() {
    const slidesToShow = Array.from(this.slides).filter(element => element.clientWidth > 0);

    this.sliderLastItem = slidesToShow[slidesToShow.length - 1];

    if (slidesToShow.length === 0) return;

    this.slidesPerPage = Math.floor(this.slider.clientWidth / slidesToShow[0].clientWidth);
    this.totalPages = slidesToShow.length - this.slidesPerPage + 1;
    this.update();
  }

  moveIndicator() {
    const indicatorWidth = 100 / this.totalPages;
    const scrollPercentage = Math.floor(100 * (this.slider.scrollLeft / (this.slider.scrollWidth - this.slider.clientWidth)));

    this.sliderIndicator.style.width = `${indicatorWidth}%`;
    this.sliderIndicator.style.left = `${scrollPercentage}%`;
  }

  update() {
    this.currentPage = Math.round(this.slider.scrollLeft / this.sliderLastItem.clientWidth) + 1;
    requestAnimationFrame(() => this.moveIndicator());
  }

  onButtonClick(event) {
    event.preventDefault();
    const slideScrollPosition = event.currentTarget.name === 'next' ?
      this.slider.scrollLeft + this.sliderLastItem.clientWidth :
      this.slider.scrollLeft - this.sliderLastItem.clientWidth;

    this.slider.scrollTo({
      left: slideScrollPosition
    });
  }
}

customElements.define('slider-component', SliderComponent);
ul {
  position: relative;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scroll-padding-left: 1rem;
  -webkit-overflow-scrolling: touch;
  display: flex;
  width: 100%;
  margin: 0;
  padding: 0;
  list-style: none;
}

li {
  width: 50%;
  min-width: auto;
  height: 100px;
  flex-shrink: 0;
  scroll-snap-align: start;
  background-color: lightblue;
  border: 2px dashed white;
}

.indicator-tray {
  position: relative;
  display: flex;
  height: 5px;
  width: 50%;
  background-color: grey;
  margin: 20px auto;
}

.indicator {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  background-color: lightblue;
}
<slider-component>
  <ul>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
  </ul>

  <div class="indicator-tray">
    <span class="indicator"></span>
  </div>

  <div class="arrows">
    <button type="button" name="previous">Prev</button>
    <button type="button" name="next">Next</button>
  </div>
</slider-component>

Answer

All credit to @DraganS for their comment – solution attached for anyone looking to build something similar:

class SliderComponent extends HTMLElement {
  constructor() {
    super();
    this.slider = this.querySelector('ul');
    this.slides = this.querySelectorAll('li');
    this.sliderTray = this.querySelector('.indicator-tray');
    this.sliderIndicator = this.querySelector('.indicator');
    this.prevButton = this.querySelector('button[name="previous"]');
    this.nextButton = this.querySelector('button[name="next"]');

    const resizeObserver = new ResizeObserver(entries => this.initialise());
    resizeObserver.observe(this.slider);

    this.slider.addEventListener('scroll', this.update.bind(this));
    this.prevButton.addEventListener('click', this.onButtonClick.bind(this));
    this.nextButton.addEventListener('click', this.onButtonClick.bind(this));
  }

  initialise() {
    const slidesToShow = Array.from(this.slides).filter(element => element.clientWidth > 0);

    this.sliderLastItem = slidesToShow[slidesToShow.length - 1];

    if (slidesToShow.length === 0) return;

    this.slidesPerPage = Math.floor(this.slider.clientWidth / slidesToShow[0].clientWidth);
    this.totalPages = slidesToShow.length - this.slidesPerPage + 1;
    this.update();
  }

  moveIndicator() {
    const indicatorWidth = 100 / this.totalPages;
    const scrollPercentage = Math.floor(100 * (this.slider.scrollLeft / (this.slider.scrollWidth - this.slider.clientWidth)));
    const left = (scrollPercentage / 100) * (this.sliderTray.clientWidth - this.sliderIndicator.clientWidth);

    this.sliderIndicator.style.width = `${indicatorWidth}%`;
    this.sliderIndicator.style.left = `${left}px`;
  }

  update() {
    this.currentPage = Math.round(this.slider.scrollLeft / this.sliderLastItem.clientWidth) + 1;
    requestAnimationFrame(() => this.moveIndicator());
  }

  onButtonClick(event) {
    event.preventDefault();
    const slideScrollPosition = event.currentTarget.name === 'next' ?
      this.slider.scrollLeft + this.sliderLastItem.clientWidth :
      this.slider.scrollLeft - this.sliderLastItem.clientWidth;

    this.slider.scrollTo({
      left: slideScrollPosition
    });
  }
}

customElements.define('slider-component', SliderComponent);
ul {
  position: relative;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scroll-padding-left: 1rem;
  -webkit-overflow-scrolling: touch;
  display: flex;
  width: 100%;
  margin: 0;
  padding: 0;
  list-style: none;
}

li {
  width: 50%;
  min-width: auto;
  height: 100px;
  flex-shrink: 0;
  scroll-snap-align: start;
  background-color: lightblue;
}

.indicator-tray {
  position: relative;
  display: flex;
  height: 5px;
  width: 50%;
  background-color: grey;
  margin: 20px auto;
}

.indicator {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  background-color: lightblue;
}
<slider-component>
  <ul>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
  </ul>

  <div class="indicator-tray">
    <span class="indicator"></span>
  </div>

  <div class="arrows">
    <button type="button" name="previous">Prev</button>
    <button type="button" name="next">Next</button>
  </div>
</slider-component>