Draggable Infinite Slider (GSAP Draggable)
A full-viewport infinite looping slider with drag and inertia, animated slide-count numbers, responsive active-slide offset logic, and prev/next button navigation — all built on the GSAP horizontalLoop helper.
Setup — External Scripts
<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>Code
<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="next 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>.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);
}
/* Button hover */
.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;
}
/* Slide caption animation */
.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;
opacity: 0;
transform: translate(-25%, 0px);
}
[data-slider="slide"].active .slide__caption {
opacity: 1;
transform: translate(0%, 0px);
}
/* Active slide styling */
[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;
}
}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 slide count
if (totalElement) totalElement.textContent = totalSlides < 10 ? `0${totalSlides}` : totalSlides;
// Build step number clones
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: on desktop the active class goes on the next sibling
const mq = window.matchMedia('(min-width: 992px)');
let useNextForActive = mq.matches;
mq.addEventListener('change', (e) => {
useNextForActive = e.matches;
if (currentEl) applyActive(currentEl, currentIndex, false);
});
let currentEl = null;
let currentIndex = 0;
function resolveActive(el) {
return useNextForActive ? (el.nextElementSibling || slides[0]) : el;
}
function applyActive(el, index, animateNumbers = true) {
if (activeElement) activeElement.classList.remove('active');
const target = resolveActive(el);
target.classList.add('active');
activeElement = target;
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) => {
currentEl = element;
currentIndex = index;
applyActive(element, index, true);
}
});
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),
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, { 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);
let newIndex = gsap.utils.wrap(0, length, index), time = times[newIndex];
if (time > tl.time() !== index > curIndex && index !== curIndex) {
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);
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, lastSnap, initChangeX, wasPlaying,
align = () => tl.progress(wrap(startProgress + (draggable.startX - draggable.x) * ratio)),
syncIndex = () => tl.closestIndex(true);
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();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| data-slider="list" | string | "" | Add to the flex container holding all slides. This is the element the horizontalLoop helper measures and the Draggable trigger target. |
| data-slider="slide" | string | "" | Add to each slide element. The .active CSS class is toggled on slides by the script based on the current index and the responsive offset logic. |
| data-slider-button="prev" | string | "" | Add to the previous navigation button. Clicking calls loop.previous() with the configured ease and duration. |
| data-slider-button="next" | string | "" | Add to the next navigation button. Clicking calls loop.next() with the configured ease and duration. |
| data-slide-count="total" | string | "" | Add to the element displaying the total slide count. The script sets its text content to the zero-padded total on init. |
| data-slide-count="step" | string | "" | Add to the element inside .slider__count-col that shows the current slide number. The script clones this element once per slide, stacks them vertically, and animates the column with GSAP on every slide change. |
Notes
- •The horizontalLoop helper is the same GSAP utility used in the Centered Looping Slider. It creates an infinite timeline where slides wrap seamlessly in both directions.
- •On desktop (≥992px) the .active class is applied to the next sibling of the onChange element, not the element itself. This creates the visual offset where the 'active' slide sits one position to the right of the centered snap point. On mobile the class is applied directly.
- •The responsive offset logic is driven by a matchMedia listener — when the breakpoint changes, applyActive is called again on the last known element with animateNumbers: false to re-apply the correct active target without animating.
- •The step counter is a rolling number column: one h2 per slide is stacked inside .slider__count-col (overflow: hidden), and gsap.to animates their y position by -100% per index step to reveal the correct number.
- •To remove the desktop active-slide offset: in applyActive, replace resolveActive(el) with el. In the click listener, replace mapClickIndex(i) with i.
- •To enable centered mode, pass center: true to horizontalLoop. This centres the active slide in the container instead of left-aligning it.
- •The prev/next button hover effect uses :has() — the hovered button scales down while siblings fade to 0.4 opacity, then the hovered button's overlay corners scale outward for a bracket-frame effect.
Guide
Slides & list
Add data-slider="list" to the flex track container and data-slider="slide" to each slide. The horizontalLoop helper measures all slides on init and on every resize to build snap points.
Slide counter
Place [data-slide-count="total"] on the total display element and [data-slide-count="step"] on a single h2 inside .slider__count-col. The script clones the step element once per slide, stacks them, and animates with GSAP.
Active slide offset
On desktop the .active class lands on the next sibling of the snap target to create an asymmetric layout. To disable this and make the snapped slide itself active, change resolveActive to return el directly and use loop.toIndex(i, ...) in the click handler.
// Remove offset — active class goes on the snapped slide
function resolveActive(el) { return el; }
// In click handler:
loop.toIndex(i, { ease: "power3", duration: 0.725 });Centering the active slide
Pass center: true in the horizontalLoop config to centre the active slide in the viewport instead of left-aligning it. You may also want to remove the overlay gradient in this mode.
const loop = horizontalLoop(slides, {
paused: true,
draggable: true,
center: true, // <-- enable centering
onChange: (element, index) => { ... }
});Easing & duration
The ease and duration used for button clicks and slide-click navigation are set in the event listeners at the bottom of initDraggableInfiniteGSAPSlider. Change the values there to adjust animation feel.
nextButton?.addEventListener("click", () =>
loop.next({ ease: "power3", duration: 0.725 })
);Drag & inertia
Drag and throw are handled inside the horizontalLoop helper via Draggable and InertiaPlugin. No additional configuration is needed. The helper snaps to the nearest slide index after each throw.