Copied SVG to clipboard
Something went wrong
Copied code to clipboard
Something went wrong

Default

User image

Default

Name

  • Osmo Discount
    -25%
The Vault/

Draggable Infinite Slider with GSAP

Draggable Infinite Slider with GSAP

Documentation

Webflow

Code

Setup: External Scripts

External Scripts in Webflow

Make sure to always put the External Scripts before the Javascript step of the resource.

In this video you learn where to put these in your Webflow project? Or how to include a paid GSAP Club plugin in your project?

HTML

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

<!-- Intertia is a paid plugin – you do NOT have permission to use below links in your projects.
Become a Club member on GSAP and host the file yourself -->
<script src="https://cdn.jsdelivr.net/gh/ilja-van-eck/osmo/assets/gsap/InertiaPlugin.min.js"></script>

Step 1: Copy structure to Webflow

Copy structure to Webflow

In the video below we described how you can copy + paste the structure of this resource to your Webflow project.

Copy to Webflow

Webflow structure is not required for this resource.

Step 1: Add HTML

HTML

Copy
<section class="section-resource is--dark">
    <div class="overlay">
      <div class="overlay-inner">
        <div class="overlay-count-row">
          <div class="count-column">
            <h2 data-slide-count="step" class="count-heading">01</h2>
          </div>
          <div class="count-row-divider"></div>
          <div class="count-column">
            <h2 data-slide-count="total" class="count-heading">04</h2>
          </div>
        </div>
        <div class="overlay-nav-row"><button aria-label="previous slide" data-slider="button-prev" class="button"><svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 17 12" fill="none" class="button-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="button-overlay">
              <div class="overlay-corner"></div>
              <div class="overlay-corner top-right"></div>
              <div class="overlay-corner bottom-left"></div>
              <div class="overlay-corner bottom-right"></div>
            </div>
          </button><button aria-label="previous slide" data-slider="button-next" class="button"><svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 17 12" fill="none" class="button-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="button-overlay">
              <div class="overlay-corner"></div>
              <div class="overlay-corner top-right"></div>
              <div class="overlay-corner bottom-left"></div>
              <div class="overlay-corner bottom-right"></div>
            </div>
          </button></div>
      </div>
    </div>
    <div class="main">
      <div class="slider-wrap">
        <div data-slider="list" class="slider-list">
          <div data-slider="slide" class="slider-slide">
            <div class="slide-inner"><img src="https://cdn.prod.website-files.com/67696d1cd4b1d776a63f0f94/67697015cad44a99732d47ae_slide-1.avif" alt="Abstract layout By FAKURIANDESIGN through Unsplash"  loading="lazy" class="slide-img">
              <div class="slide-caption">
                <div class="caption-dot"></div>
                <p class="caption">Layout nº004</p>
              </div>
            </div>
          </div>
          <div data-slider="slide" class="slider-slide active">
            <div class="slide-inner"><img loading="lazy" src="https://cdn.prod.website-files.com/67696d1cd4b1d776a63f0f94/67697015cad44a99732d47c6_slide-2.avif" alt="Abstract layout By FAKURIANDESIGN through Unsplash" class="slide-img">
              <div class="slide-caption">
                <div class="caption-dot"></div>
                <p class="caption">Layout nº001</p>
              </div>
            </div>
          </div>
          <div data-slider="slide" class="slider-slide">
            <div class="slide-inner"><img src="https://cdn.prod.website-files.com/67696d1cd4b1d776a63f0f94/67697015cad44a99732d47d2_slide-3.avif" alt="Abstract layout By FAKURIANDESIGN through Unsplash"  loading="lazy" class="slide-img">
              <div class="slide-caption">
                <div class="caption-dot"></div>
                <p class="caption">Layout nº002</p>
              </div>
            </div>
          </div>
          <div data-slider="slide" class="slider-slide">
            <div class="slide-inner"><img src="https://cdn.prod.website-files.com/67696d1cd4b1d776a63f0f94/67697015cad44a99732d47ba_slide-4.avif" alt="Abstract layout By FAKURIANDESIGN through Unsplash"  loading="lazy" class="slide-img">
              <div class="slide-caption">
                <div class="caption-dot"></div>
                <p class="caption">Layout nº003</p>
              </div>
            </div>
          </div>
          <div data-slider="slide" class="slider-slide">
            <div class="slide-inner"><img  src="https://cdn.prod.website-files.com/67696d1cd4b1d776a63f0f94/67697015cad44a99732d47c6_slide-2.avif" alt="Abstract layout By FAKURIANDESIGN through Unsplash" loading="lazy" class="slide-img">
              <div class="slide-caption">
                <div class="caption-dot"></div>
                <p class="caption">Layout nº005</p>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </section>

HTML structure is not required for this resource.

Step 2: Add CSS

CSS

Copy
.section-resource.is--dark {
  background-color: #000;
}

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

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

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

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

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

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

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

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

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

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

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

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

.button {
  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;
}

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

.count-column {
  height: 1em;
  overflow: hidden;
}

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

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

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

.caption {
  margin-top: 0;
  margin-bottom: 0;
  font-size: .75em;
}

.slider-slide {
  flex: none;
  width: 42.5em;
  height: 28em;
  padding-left: 1.25em;
  padding-right: 1.25em;
  transition: opacity .4s;
  position: relative;
}

.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;
}

.slide-caption {
  z-index: 2;
  grid-column-gap: .4em;
  grid-row-gap: .4em;
  color: #131313;
  white-space: nowrap;
  background-color: #efeeec;
  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-img {
  object-fit: cover;
  width: 100%;
  height: 100%;
}

Step 2: Add custom Javascript

Custom Javascript in Webflow

In this video, Ilja gives you some guidance about using JavaScript in Webflow:

Step 2: Add Javascript

Step 3: Add Javascript

Javascript

Copy
document.addEventListener("DOMContentLoaded", (event) => {
  
gsap.registerPlugin(Draggable, InertiaPlugin)

function initSlider(){

  const wrapper = document.querySelector('[data-slider="list"]')  
  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;

  // Update total slides text, prepend 0 if less than 10
  totalElement.textContent = totalSlides < 10 ? `0${totalSlides}` : totalSlides;

  // Create step elements dynamically
  stepsParent.innerHTML = ''; // Clear any existing steps
  slides.forEach((_, index) => {
    const stepClone = stepElement.cloneNode(true); // Clone the single step
    stepClone.textContent = index + 1 < 10 ? `0${index + 1}` : index + 1;
    stepsParent.appendChild(stepClone); // Append to the parent container
  });

  // Dynamically generated steps
  const allSteps = stepsParent.querySelectorAll('[data-slide-count="step"]');
  
  const loop = horizontalLoop(slides, {
    paused: true, 
    draggable: true, 
    center: false,
    onChange: (element, index) => { 
      
      // We add the active class to the 'next' element because our design is offset slightly.
      activeElement && activeElement.classList.remove("active");
      const nextSibling = element.nextElementSibling || slides[0]; 
      nextSibling.classList.add("active");
      activeElement = nextSibling;
      
      // Move the number to the correct spot
      gsap.to(allSteps, { y: `${-100 * index}%`, ease: "power3", duration: 0.45 });
    }
  });
  
  // Similar to above, we substract 1 from our clicked index on click because our design is offset
  slides.forEach((slide, i) => slide.addEventListener("click", () => loop.toIndex(i - 1, {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}));

}

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;
}
  
initSlider()

});

Step 3: Add custom CSS

Step 2: Add custom CSS

Custom CSS in Webflow

Curious about where to put custom CSS in Webflow? Ilja explains it in the below video:

CSS

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

.button:hover .button-overlay{ transform: scale(1.4); }
.overlay-nav-row:hover:has(.button:hover) .button{ opacity: 0.4; }
.button: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; }

html:not(.wf-design-mode) .slide-caption{ opacity: 0; transform:translate(-25%, 0px) }
html:not(.wf-design-mode) [data-slider="slide"].active .slide-caption{ opacity: 1; transform:translate(0%, 0px) }



/* Styling of active slide */
html:not(.wf-design-mode) [data-slider="slide"]{ opacity: 0.2; }
html:not(.wf-design-mode) [data-slider="slide"].active { opacity: 1; } 
html:not(.wf-design-mode) [data-slider="slide"].active .slide-caption{ transition-delay:0.3s;} 

Customisation

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 initSlider 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 initSlider() function, where we initialize the horizontalLoop,  there's a center option you can set to true.

Easing & Duration:

At the end of the initSlider() 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.

Resource Details

Draggable
Slider
GSAP
Information
Card
Image

Original source

Ilja 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.