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.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
<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
.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
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
/* 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
Last updated
January 23, 2025
Type
The Vault
Category
Sliders & Marquees
Need help?
Join Slack