🚀

Update available

We just released a new resource or update, refresh the Vault to access the latest version.

Cancel

Refresh now

Harri

Profile Picture

Harri

Lemke

Draggable Infinite Slider (GSAP Draggable)

Documentation

Webflow

Code

Setup: External Scripts

HTML

Copy
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/Draggable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/InertiaPlugin.min.js"></script>

Step 1: Add HTML

HTML

Copy
<div class="slider__section">
  <div class="slider__overlay">
    <div class="slider__overlay-inner">
      <div class="slider__overlay-count">
        <div class="slider__count-col">
          <h2 data-slide-count="step" class="slider__count-heading">01</h2>
        </div>
        <div class="slider__count-divider"></div>
        <div class="slider__count-col">
          <h2 data-slide-count="total" class="slider__count-heading">04</h2>
        </div>
      </div>
      <div class="slider__overlay-nav">
        <button aria-label="previous slide" data-slider-button="prev" class="slider__btn">
          <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 17 12" fill="none" class="slider__btn-arrow">
            <path d="M6.28871 12L7.53907 10.9111L3.48697 6.77778H16.5V5.22222H3.48697L7.53907 1.08889L6.28871 0L0.5 6L6.28871 12Z" fill="currentColor"></path>
          </svg>
          <div class="slider__btn-overlay">
            <div class="slider__btn-overlay-corner"></div>
            <div class="slider__btn-overlay-corner top-right"></div>
            <div class="slider__btn-overlay-corner bottom-left"></div>
            <div class="slider__btn-overlay-corner bottom-right"></div>
          </div>
        </button>
        <button aria-label="previous slide" data-slider-button="next" class="slider__btn">
          <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 17 12" fill="none" class="slider__btn-arrow next">
            <path d="M6.28871 12L7.53907 10.9111L3.48697 6.77778H16.5V5.22222H3.48697L7.53907 1.08889L6.28871 0L0.5 6L6.28871 12Z" fill="currentColor"></path>
          </svg>
          <div class="slider__btn-overlay">
            <div class="slider__btn-overlay-corner"></div>
            <div class="slider__btn-overlay-corner top-right"></div>
            <div class="slider__btn-overlay-corner bottom-left"></div>
            <div class="slider__btn-overlay-corner bottom-right"></div>
          </div>
        </button>
      </div>
    </div>
  </div>
  <div class="slider__main">
    <div class="slider__wrap">
      <div data-slider="list" class="slider__list">
        <div data-slider="slide" class="slider__slide">
          <div class="slider__slide-inner"><img src="https://cdn.prod.website-files.com/67696d1cd4b1d776a63f0f94/690b581ba59c96e073460cd1_Cinematic%20Motion%20Portrait.avif" class="slide__img">
            <div class="slide__caption">
              <div class="slide__caption-dot"></div>
              <p class="slide__caption-label">Image nº005</p>
            </div>
          </div>
        </div>
        <div data-slider="slide" class="slider__slide active">
          <div class="slider__slide-inner"><img src="https://cdn.prod.website-files.com/67696d1cd4b1d776a63f0f94/690b581b4e66ce6d99185126_Child%20in%20Sunset%20Meadow.avif" class="slide__img">
            <div class="slide__caption">
              <div class="slide__caption-dot"></div>
              <p class="slide__caption-label">Image nº001</p>
            </div>
          </div>
        </div>
        <div data-slider="slide" class="slider__slide">
          <div class="slider__slide-inner"><img src="https://cdn.prod.website-files.com/67696d1cd4b1d776a63f0f94/690b581b644385ab3c4845f8_Woman%20in%20Coastal%20Field.avif" class="slide__img">
            <div class="slide__caption">
              <div class="slide__caption-dot"></div>
              <p class="slide__caption-label">Image nº002</p>
            </div>
          </div>
        </div>
        <div data-slider="slide" class="slider__slide">
          <div class="slider__slide-inner"><img src="https://cdn.prod.website-files.com/67696d1cd4b1d776a63f0f94/690b581bae1e27262dcfe889_Runner%20at%20Golden%20Hour.avif" class="slide__img">
            <div class="slide__caption">
              <div class="slide__caption-dot"></div>
              <p class="slide__caption-label">Image nº003</p>
            </div>
          </div>
        </div>
        <div data-slider="slide" class="slider__slide">
          <div class="slider__slide-inner"><img src="https://cdn.prod.website-files.com/67696d1cd4b1d776a63f0f94/690b581b7c6e8ac0e1960406_Golden%20Hour%20Serenity.avif" class="slide__img">
            <div class="slide__caption">
              <div class="slide__caption-dot"></div>
              <p class="slide__caption-label">Layout nº004</p>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

Step 2: Add CSS

CSS

Copy
.slider__section{
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  display: flex;
  position: relative;  
  background-color: #20261b;
}

.slider__main {
  z-index: 0;
  width: 100%;
  height: 100%;
  position: absolute;
  inset: 0%;
  overflow: hidden;
}

.slider__wrap {
  justify-content: flex-start;
  align-items: center;
  width: 100%;
  height: 100%;
  display: flex;
}

.slider__list {
  flex-flow: row;
  justify-content: flex-start;
  align-items: stretch;
  display: flex;
  position: relative;
}

.slider__slide {
  aspect-ratio: 3 / 2;
  flex: none;
  width: 36vw;
  padding-left: 1.25em;
  padding-right: 1.25em;
  transition: opacity .4s;
  position: relative;
}

.slider__slide-inner {
  border-radius: .5em;
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
}

.slide__img {
  object-fit: cover;
  width: 100%;
  height: 100%;
}

.slide__caption {
  z-index: 2;
  grid-column-gap: .4em;
  grid-row-gap: .4em;
  -webkit-backdrop-filter: blur(5px);
  backdrop-filter: blur(5px);
  color: #fff;
  white-space: nowrap;
  background-color: #efeeec26;
  border-radius: .25em;
  justify-content: flex-start;
  align-items: center;
  padding: .4em .75em .4em .5em;
  display: flex;
  position: absolute;
  top: 1.25em;
  left: 1.25em;
  overflow: hidden;
}

.slide__caption-dot {
  background-color: #a1ff62;
  border-radius: 10em;
  flex: none;
  width: .5em;
  height: .5em;
}

.slide__caption-label {
  margin-top: 0;
  margin-bottom: 0;
  font-size: .75em;
  line-height: 1.5;
}

.slider__overlay {
  z-index: 2;
  color: #fff;
  background-image: linear-gradient(90deg, #20261b 85%, #20261b00);
  justify-content: flex-start;
  align-items: center;
  width: 36vw;
  height: 100%;
  padding-left: 2em;
  display: flex;
  position: absolute;
  inset: 0% auto 0% 0%;
}

.slider__overlay-inner {
  flex-flow: column;
  justify-content: space-between;
  align-items: flex-start;
  height: 28.125em;
  display: flex;
}

.slider__overlay-count {
  grid-column-gap: .2em;
  grid-row-gap: .2em;
  flex-flow: row;
  justify-content: flex-start;
  align-items: center;
  font-size: 4.5em;
  font-weight: 700;
  display: flex;
}

.slider__count-col {
  height: 1em;
  overflow: hidden;
}

.slider__count-heading {
  width: 2ch;
  margin-top: 0;
  margin-bottom: 0;
  font-size: 1em;
  font-weight: 400;
  line-height: 1;
}

.slider__count-divider {
  background-color: #efeeec;
  width: 2px;
  height: .75em;
  transform: rotate(15deg);
}

.slider__overlay-nav {
  grid-column-gap: 2em;
  grid-row-gap: 2em;
  display: flex;
}

.slider__btn {
  color: #fff;
  background-color: #0000;
  border: 1px solid #fff3;
  border-radius: .4em;
  justify-content: center;
  align-items: center;
  width: 4em;
  height: 4em;
  padding: 0;
  display: flex;
  position: relative;
}

.slider__btn-arrow {
  flex: none;
  width: 1em;
  height: .75em;
}

.slider__btn-arrow.next {
  transform: rotate(180deg);
}

.slider__btn-overlay {
  z-index: 2;
  position: absolute;
  inset: -1px;
}

.slider__btn-overlay-corner {
  border-top: 1px solid #efeeec;
  border-left: 1px solid #efeeec;
  border-top-left-radius: .4em;
  width: 1em;
  height: 1em;
}

.slider__btn-overlay-corner.top-right {
  position: absolute;
  inset: 0% 0% auto auto;
  transform: rotate(90deg);
}

.slider__btn-overlay-corner.bottom-right {
  position: absolute;
  inset: auto 0% 0% auto;
  transform: rotate(180deg);
}

.slider__btn-overlay-corner.bottom-left {
  position: absolute;
  inset: auto auto 0% 0%;
  transform: rotate(-90deg);
}

/* Previous and next Button*/ 
.slider__btn,
.slider__btn-overlay { 
  transition: transform 0.475s cubic-bezier(0.625, 0.05, 0, 1), opacity 0.475s cubic-bezier(0.625, 0.05, 0, 1)
}

.slider__btn:hover .slider__btn-overlay { 
  transform: scale(1.4);
}

.slider__overlay-nav:hover:has(.slider__btn:hover) .slider__btn { 
  opacity: 0.4;
}

.slider__btn:hover { 
  transform: scale(0.85);
  opacity: 1 !important;
}

/* Styling of active slide's caption */
.slide__caption {
  transition: transform 0.525s cubic-bezier(0.625, 0.05, 0, 1), opacity 0.525s cubic-bezier(0.625, 0.05, 0, 1);
  transition-delay: 0s;
}

.slide__caption {
  opacity: 0;
  transform:translate(-25%, 0px);
}

[data-slider="slide"].active .slide__caption {
  opacity: 1;
  transform:translate(0%, 0px);
}

/* Styling of active slide */
[data-slider="slide"]{ opacity: 0.2; }
[data-slider="slide"].active { opacity: 1; } 
[data-slider="slide"].active .slide__caption{ transition-delay: 0.3s; } 

@media screen and (max-width: 991px) {

  .slider__main {
    position: relative;
  }

  .slider__slide {
    width: 75vw;
  }

  .slider__overlay {
    width: 100%;
    position: relative;
    inset: auto;
    padding-bottom: 2em;
  }

  .slider__overlay-inner {
    grid-column-gap: 2em;
    grid-row-gap: 2em;
    height: auto;
  }

}

@media screen and (max-width: 479px) {
  .slider__overlay {
    padding-left: 1.25em;
  }

  .slider__slide {
    width: 90vw;
    padding-left: .5em;
    padding-right: .5em;
  }

  .slide__caption {
    top: .5em;
    left: .5em;
  }
}

Step 2: Add Javascript

Step 3: Add Javascript

Javascript

Copy
gsap.registerPlugin(Draggable, InertiaPlugin)

function initDraggableInfiniteGSAPSlider(){

  const wrapper = document.querySelector('[data-slider="list"]');
  if (!wrapper) return;

  const slides = gsap.utils.toArray('[data-slider="slide"]');
  const nextButton = document.querySelector('[data-slider-button="next"]');
  const prevButton = document.querySelector('[data-slider-button="prev"]');

  const totalElement = document.querySelector('[data-slide-count="total"]');
  const stepElement = document.querySelector('[data-slide-count="step"]');
  const stepsParent = stepElement?.parentElement;

  let activeElement;
  const totalSlides = slides.length;

  // Total
  if (totalElement) totalElement.textContent = totalSlides < 10 ? `0${totalSlides}` : totalSlides;

  // Steps (clone)
  if (stepsParent && stepElement) {
    stepsParent.innerHTML = '';
    slides.forEach((_, index) => {
      const stepClone = stepElement.cloneNode(true);
      stepClone.textContent = index + 1 < 10 ? `0${index + 1}` : (index + 1);
      stepsParent.appendChild(stepClone);
    });
  }
  const allSteps = stepsParent ? stepsParent.querySelectorAll('[data-slide-count="step"]') : [];

  // Responsive
  const mq = window.matchMedia('(min-width: 992px)');
  let useNextForActive = mq.matches;
  mq.addEventListener('change', (e) => {
    useNextForActive = e.matches;
    // Re-apply active to the correct element for this breakpoint
    if (currentEl) {
      applyActive(currentEl, currentIndex, /*animateNumbers=*/false);
    }
  });

  // Keep track of the current element & index from onChange
  let currentEl = null;
  let currentIndex = 0;

  function resolveActive(el) {
    return useNextForActive ? (el.nextElementSibling || slides[0]) : el;
  }

  function applyActive(el, index, animateNumbers = true) {
    // Swap active class
    if (activeElement) activeElement.classList.remove('active');
    const target = resolveActive(el);
    target.classList.add('active');
    activeElement = target;

    // Update numbers (animate or snap)
    if (allSteps.length) {
      if (animateNumbers) {
        gsap.to(allSteps, { y: `${-100 * index}%`, ease: "power3", duration: 0.45 });
      } else {
        gsap.set(allSteps, { y: `${-100 * index}%` });
      }
    }
  }

  const loop = horizontalLoop(slides, {
    paused: true,
    draggable: true,
    center: false,
    onChange: (element, index) => {
      // remember latest
      currentEl = element;
      currentIndex = index;
      // apply for current breakpoint
      applyActive(element, index, true);
    }
  });

  // Click -> go to index (offset only on desktop offset design)
  function mapClickIndex(i) { return useNextForActive ? (i - 1) : i; }
  slides.forEach((slide, i) => {
    slide.addEventListener("click", () => {
      if (slide.classList.contains("active")) return;
      loop.toIndex(mapClickIndex(i), { ease: "power3", duration: 0.725 });
    });
  });

  nextButton?.addEventListener("click", () => loop.next({ ease:"power3", duration: 0.725 }));
  prevButton?.addEventListener("click", () => loop.previous({ ease:"power3", duration: 0.725 }));

  if (!currentEl && slides[0]) {
    currentEl = slides[0];
    currentIndex = 0;
    applyActive(currentEl, currentIndex, false);
  }
}

function horizontalLoop(items, config) {
  let timeline;
  items = gsap.utils.toArray(items);
  config = config || {};
  gsap.context(() => { 
    let onChange = config.onChange,
      lastIndex = 0,
      tl = gsap.timeline({repeat: config.repeat, onUpdate: onChange && function() {
          let i = tl.closestIndex();
          if (lastIndex !== i) {
            lastIndex = i;
            onChange(items[i], i);
          }
        }, paused: config.paused, defaults: {ease: "none"}, onReverseComplete: () => tl.totalTime(tl.rawTime() + tl.duration() * 100)}),
      length = items.length,
      startX = items[0].offsetLeft,
      times = [],
      widths = [],
      spaceBefore = [],
      xPercents = [],
      curIndex = 0,
      indexIsDirty = false,
      center = config.center,
      pixelsPerSecond = (config.speed || 1) * 100,
      snap = config.snap === false ? v => v : gsap.utils.snap(config.snap || 1), // some browsers shift by a pixel to accommodate flex layouts, so for example if width is 20% the first element's width might be 242px, and the next 243px, alternating back and forth. So we snap to 5 percentage points to make things look more natural
      timeOffset = 0,
      container = center === true ? items[0].parentNode : gsap.utils.toArray(center)[0] || items[0].parentNode,
      totalWidth,
      getTotalWidth = () => items[length-1].offsetLeft + xPercents[length-1] / 100 * widths[length-1] - startX + spaceBefore[0] + items[length-1].offsetWidth * gsap.getProperty(items[length-1], "scaleX") + (parseFloat(config.paddingRight) || 0),
      populateWidths = () => {
        let b1 = container.getBoundingClientRect(), b2;
        items.forEach((el, i) => {
          widths[i] = parseFloat(gsap.getProperty(el, "width", "px"));
          xPercents[i] = snap(parseFloat(gsap.getProperty(el, "x", "px")) / widths[i] * 100 + gsap.getProperty(el, "xPercent"));
          b2 = el.getBoundingClientRect();
          spaceBefore[i] = b2.left - (i ? b1.right : b1.left);
          b1 = b2;
        });
        gsap.set(items, { // convert "x" to "xPercent" to make things responsive, and populate the widths/xPercents Arrays to make lookups faster.
          xPercent: i => xPercents[i]
        });
        totalWidth = getTotalWidth();
      },
      timeWrap,
      populateOffsets = () => {
        timeOffset = center ? tl.duration() * (container.offsetWidth / 2) / totalWidth : 0;
        center && times.forEach((t, i) => {
          times[i] = timeWrap(tl.labels["label" + i] + tl.duration() * widths[i] / 2 / totalWidth - timeOffset);
        });
      },
      getClosest = (values, value, wrap) => {
        let i = values.length,
          closest = 1e10,
          index = 0, d;
        while (i--) {
          d = Math.abs(values[i] - value);
          if (d > wrap / 2) {
            d = wrap - d;
          }
          if (d < closest) {
            closest = d;
            index = i;
          }
        }
        return index;
      },
      populateTimeline = () => {
        let i, item, curX, distanceToStart, distanceToLoop;
        tl.clear();
        for (i = 0; i < length; i++) {
          item = items[i];
          curX = xPercents[i] / 100 * widths[i];
          distanceToStart = item.offsetLeft + curX - startX + spaceBefore[0];
          distanceToLoop = distanceToStart + widths[i] * gsap.getProperty(item, "scaleX");
          tl.to(item, {xPercent: snap((curX - distanceToLoop) / widths[i] * 100), duration: distanceToLoop / pixelsPerSecond}, 0)
            .fromTo(item, {xPercent: snap((curX - distanceToLoop + totalWidth) / widths[i] * 100)}, {xPercent: xPercents[i], duration: (curX - distanceToLoop + totalWidth - curX) / pixelsPerSecond, immediateRender: false}, distanceToLoop / pixelsPerSecond)
            .add("label" + i, distanceToStart / pixelsPerSecond);
          times[i] = distanceToStart / pixelsPerSecond;
        }
        timeWrap = gsap.utils.wrap(0, tl.duration());
      },
      refresh = (deep) => {
        let progress = tl.progress();
        tl.progress(0, true);
        populateWidths();
        deep && populateTimeline();
        populateOffsets();
        deep && tl.draggable ? tl.time(times[curIndex], true) : tl.progress(progress, true);
      },
      onResize = () => refresh(true),
      proxy;
    gsap.set(items, {x: 0});
    populateWidths();
    populateTimeline();
    populateOffsets();
    window.addEventListener("resize", onResize);
    function toIndex(index, vars) {
      vars = vars || {};
      (Math.abs(index - curIndex) > length / 2) && (index += index > curIndex ? -length : length); // always go in the shortest direction
      let newIndex = gsap.utils.wrap(0, length, index),
        time = times[newIndex];
      if (time > tl.time() !== index > curIndex && index !== curIndex) { // if we're wrapping the timeline's playhead, make the proper adjustments
        time += tl.duration() * (index > curIndex ? 1 : -1);
      }
      if (time < 0 || time > tl.duration()) {
        vars.modifiers = {time: timeWrap};
      }
      curIndex = newIndex;
      vars.overwrite = true;
      gsap.killTweensOf(proxy);    
      return vars.duration === 0 ? tl.time(timeWrap(time)) : tl.tweenTo(time, vars);
    }
    tl.toIndex = (index, vars) => toIndex(index, vars);
    tl.closestIndex = setCurrent => {
      let index = getClosest(times, tl.time(), tl.duration());
      if (setCurrent) {
        curIndex = index;
        indexIsDirty = false;
      }
      return index;
    };
    tl.current = () => indexIsDirty ? tl.closestIndex(true) : curIndex;
    tl.next = vars => toIndex(tl.current()+1, vars);
    tl.previous = vars => toIndex(tl.current()-1, vars);
    tl.times = times;
    tl.progress(1, true).progress(0, true); // pre-render for performance
    if (config.reversed) {
      tl.vars.onReverseComplete();
      tl.reverse();
    }
    if (config.draggable && typeof(Draggable) === "function") {
      proxy = document.createElement("div")
      let wrap = gsap.utils.wrap(0, 1),
        ratio, startProgress, draggable, dragSnap, lastSnap, initChangeX, wasPlaying,
        align = () => tl.progress(wrap(startProgress + (draggable.startX - draggable.x) * ratio)),
        syncIndex = () => tl.closestIndex(true);
      typeof(InertiaPlugin) === "undefined" && console.warn("InertiaPlugin required for momentum-based scrolling and snapping. https://greensock.com/club");
      draggable = Draggable.create(proxy, {
        trigger: items[0].parentNode,
        type: "x",
        onPressInit() {
          let x = this.x;
          gsap.killTweensOf(tl);
          wasPlaying = !tl.paused();
          tl.pause();
          startProgress = tl.progress();
          refresh();
          ratio = 1 / totalWidth;
          initChangeX = (startProgress / -ratio) - x;
          gsap.set(proxy, {x: startProgress / -ratio});
        },
        onDrag: align,
        onThrowUpdate: align,
        overshootTolerance: 0,
        inertia: true,
        snap(value) {
          if (Math.abs(startProgress / -ratio - this.x) < 10) {
            return lastSnap + initChangeX
          }
          let time = -(value * ratio) * tl.duration(),
            wrappedTime = timeWrap(time),
            snapTime = times[getClosest(times, wrappedTime, tl.duration())],
            dif = snapTime - wrappedTime;
          Math.abs(dif) > tl.duration() / 2 && (dif += dif < 0 ? tl.duration() : -tl.duration());
          lastSnap = (time + dif) / tl.duration() / -ratio;
          return lastSnap;
        },
        onRelease() {
          syncIndex();
          draggable.isThrowing && (indexIsDirty = true);
        },
        onThrowComplete: () => {
          syncIndex();
          wasPlaying && tl.play();
        }
      })[0];
      tl.draggable = draggable;
    }
    tl.closestIndex(true);
    lastIndex = curIndex;
    onChange && onChange(items[curIndex], curIndex);
    timeline = tl;
    return () => window.removeEventListener("resize", onResize); 
  });
  return timeline;
}

// Initialize Draggable Infinite GSAP Slider
document.addEventListener('DOMContentLoaded', function() {
  initDraggableInfiniteGSAPSlider();
});

Implementation

If you want to apply this slider in a different design:

Our specific design required us to 'offset' the slide that's considered active. So in our initDraggableInfiniteGSAPSlide() function, specifically in the onChange function, you'll find that we add the active class to the nextSibling.

If you don't want this, just directly set the 'active' class on the activeElement and remove the line where we define activeElement = nextSibling.

Lastly, in the click listener for each slide (so that the slider moves to the slide you click) we can change the loop.toIndex(i - 1,{...}) to just say loop.toIndex(i,{...})

Center the active slide:

In the initDraggableInfiniteGSAPSlide() function, where we initialize the horizontalLoop,  there's a center option you can set to true.

Easing & Duration:

At the end of the initDraggableInfiniteGSAPSlide() function, there's event listeners for both the prev/next buttons and 'click to slide'. This is where you can control the ease and duration as you would for any other GSAP tween.

Live preview

Osmo Robot AI

Copy context for AI

Beta

Webflow

HTML/CSS/JS

Save video

Copy share link

Resource details

  • Published

    December 5, 2024

  • Category

    Sliders & Marquees

  • Popularity

    2.8K visitors

  • Need help?

    Join Slack

Draggable
Slider
GSAP
Information
Card
Image

Original source

Ilja van EckIlja van Eck

Creator Credits

We always strive to credit creators as accurately as possible. While similar concepts might appear online, we aim to provide proper and respectful attribution.

s