Draggable Infinite Slider (GSAP Draggable)
Documentation
Webflow
Code
Setup: External Scripts
HTML
<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
<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
.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
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.
Resource details
Published
December 5, 2024
Category
Sliders & Marquees
Popularity
2.8K visitors
Need help?
Join Slack