Draggable Infinite Slider (GSAP Draggable)

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
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/Draggable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/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
<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>HTML structure is not required for this resource.
Step 2: Add CSS
CSS
.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 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
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();
});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
/* 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;
}
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; } 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.
Resource details
Last updated
November 13, 2025
Category
Sliders & Marquees
Need help?
Join Slack































































































































