
An infinitely scrolling card carousel implemented with the GSAP Animation Library.
See it in action:
See the Pen
Infinite Scrolling Cards with GSAP and ScrollTrigger (continuous snap) by GreenSock (@GreenSock)
on CodePen.
How to use it:
1. Add cards together with navigation controls to the carousel.
<div class="carousel">
<ul class="cards">
<li>0</li>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
...
</ul>
<div class="actions">
<button class="prev">Prev</button>
<button class="next">Next</button>
</div>
</div>
2. The core CSS/CSS3 styles for the carousel.
.carousel {
position: absolute;
width: 100%;
height: 100vh;
overflow: hidden;
}
.cards {
position: absolute;
width: 14rem;
height: 18rem;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
}
.cards li {
list-style: none;
padding: 0;
margin: 0;
width: 14rem;
height: 18rem;
text-align: center;
line-height: 18rem;
font-size: 2rem;
font-family: sans-serif;
background-color: #9d7cce;
position: absolute;
top: 0;
left: 0;
border-radius: 0.8rem;
}
.actions {
position: absolute;
bottom: 25px;
left: 50%;
transform: translateX(-50%);
}3. Load the needed GSAP and ScrollTrigger JavaScript libraries in the document.
<script src='https://unpkg.co/gsap@3/dist/gsap.min.js'></script> <script src='https://unpkg.com/gsap@3/dist/ScrollTrigger.min.js'></script>
4. The core JavaScript to activate the carousel.
gsap.registerPlugin(ScrollTrigger);
// gets iterated when we scroll all the way to the end or start and wraps around - allows us to smoothly continue the playhead scrubbing in the correct direction.
let iteration = 0;
// spacing of the cards (stagger)
const spacing = 0.1,
snap = gsap.utils.snap(spacing), // we'll use this to snap the playhead on the seamlessLoop
cards = gsap.utils.toArray('.cards li'),
seamlessLoop = buildSeamlessLoop(cards, spacing),
scrub = gsap.to(seamlessLoop, { // we reuse this tween to smoothly scrub the playhead on the seamlessLoop
totalTime: 0,
duration: 0.5,
ease: "power3",
paused: true
}),
trigger = ScrollTrigger.create({
start: 0,
onUpdate(self) {
if (self.progress === 1 && self.direction > 0 && !self.wrapping) {
wrapForward(self);
} else if (self.progress < 1e-5 && self.direction < 0 && !self.wrapping) {
wrapBackward(self);
} else {
scrub.vars.totalTime = snap((iteration + self.progress) * seamlessLoop.duration());
scrub.invalidate().restart(); // to improve performance, we just invalidate and restart the same tween. No need for overwrites or creating a new tween on each update.
self.wrapping = false;
}
},
end: "+=3000",
pin: ".gallery"
});
// when the ScrollTrigger reaches the end, loop back to the beginning seamlessly
function wrapForward(trigger) {
iteration++;
trigger.wrapping = true;
trigger.scroll(trigger.start + 1);
}
// when the ScrollTrigger reaches the start again (in reverse), loop back to the end seamlessly
function wrapBackward(trigger) {
iteration--;
if (iteration < 0) { // to keep the playhead from stopping at the beginning, we jump ahead 10 iterations
iteration = 9;
seamlessLoop.totalTime(seamlessLoop.totalTime() + seamlessLoop.duration() * 10);
scrub.pause(); // otherwise it may update the totalTime right before the trigger updates, making the starting value different than what we just set above.
}
trigger.wrapping = true;
trigger.scroll(trigger.end - 1);
}
// moves the scroll position to the place that corresponds to the totalTime value of the seamlessLoop, and wraps if necessary.
function scrubTo(totalTime) {
let progress = (totalTime - seamlessLoop.duration() * iteration) / seamlessLoop.duration();
if (progress > 1) {
wrapForward(trigger);
} else if (progress < 0) {
wrapBackward(trigger);
} else {
trigger.scroll(trigger.start + progress * (trigger.end - trigger.start));
}
}
document.querySelector(".next").addEventListener("click", () => scrubTo(scrub.vars.totalTime + spacing));
document.querySelector(".prev").addEventListener("click", () => scrubTo(scrub.vars.totalTime - spacing));
function buildSeamlessLoop(items, spacing) {
let overlap = Math.ceil(1 / spacing), // number of EXTRA animations on either side of the start/end to accommodate the seamless looping
startTime = items.length * spacing + 0.5, // the time on the rawSequence at which we'll start the seamless loop
loopTime = (items.length + overlap) * spacing + 1, // the spot at the end where we loop back to the startTime
rawSequence = gsap.timeline({paused: true}), // this is where all the "real" animations live
seamlessLoop = gsap.timeline({ // this merely scrubs the playhead of the rawSequence so that it appears to seamlessly loop
paused: true,
repeat: -1, // to accommodate infinite scrolling/looping
onRepeat() { // works around a super rare edge case bug that's fixed GSAP 3.6.1
this._time === this._dur && (this._tTime += this._dur - 0.01);
}
}),
l = items.length + overlap * 2,
time = 0,
i, index, item;
// set initial state of items
gsap.set(items, {xPercent: 400, opacity: 0, scale: 0});
// now loop through and create all the animations in a staggered fashion. Remember, we must create EXTRA animations at the end to accommodate the seamless looping.
for (i = 0; i < l; i++) {
index = i % items.length;
item = items[index];
time = i * spacing;
rawSequence.fromTo(item, {scale: 0, opacity: 0}, {scale: 1, opacity: 1, zIndex: 100, duration: 0.5, yoyo: true, repeat: 1, ease: "power1.in", immediateRender: false}, time)
.fromTo(item, {xPercent: 400}, {xPercent: -400, duration: 1, ease: "none", immediateRender: false}, time);
i <= items.length && seamlessLoop.add("label" + i, time); // we don't really need these, but if you wanted to jump to key spots using labels, here ya go.
}
// here's where we set up the scrubbing of the playhead to make it appear seamless.
rawSequence.time(startTime);
seamlessLoop.to(rawSequence, {
time: loopTime,
duration: loopTime - startTime,
ease: "none"
}).fromTo(rawSequence, {time: overlap * spacing + 1}, {
time: startTime,
duration: startTime - (overlap * spacing + 1),
immediateRender: false,
ease: "none"
});
return seamlessLoop;
} 





