Cascading Slider
A cascading carousel that uses clip-path to reveal and hide slides. Supports responsive breakpoints, keyboard navigation, click-to-navigate, and automatic slide duplication for seamless looping. Works with any number of slides.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>Code
<div data-cascading-slider-wrap class="cascading-slider" aria-label="Featured content" aria-roledescription="carousel">
<div class="cascading-slider__collection">
<div data-cascading-viewport class="cascading-slider__list">
<div aria-roledescription="slide" data-cascading-slide role="group" class="cascading-slider__item">
<div class="cascading-slider__item-inner">
<div class="cascading-slider__item-bg">
<img src="https://cdn.prod.website-files.com/699ecbb03f86e84bad7a74f3/699eea7d454cb9d5091ac8ce_cascading-carousel-3.avif" loading="eager" draggable="false" class="cascading-slider__img">
</div>
<div class="cascading-slider__item-content">
<h3 class="cascading-slider__h">Annual overview</h3>
</div>
</div>
</div>
<div aria-roledescription="slide" data-cascading-slide role="group" class="cascading-slider__item">
<div class="cascading-slider__item-inner">
<div class="cascading-slider__item-bg">
<img src="https://cdn.prod.website-files.com/699ecbb03f86e84bad7a74f3/699eec227ff9240c1e047cf3_cascading-carousel-2.avif" loading="lazy" draggable="false" class="cascading-slider__img">
</div>
<div class="cascading-slider__item-content">
<h3 class="cascading-slider__h">Sustainability efforts</h3>
</div>
</div>
</div>
<div aria-roledescription="slide" data-cascading-slide role="group" class="cascading-slider__item">
<div class="cascading-slider__item-inner">
<div class="cascading-slider__item-bg">
<img src="https://cdn.prod.website-files.com/699ecbb03f86e84bad7a74f3/699eea7d6333786f72559958_cascading-carousel-5.avif" loading="lazy" draggable="false" class="cascading-slider__img">
</div>
<div class="cascading-slider__item-content">
<h3 class="cascading-slider__h">Product development</h3>
</div>
</div>
</div>
<div aria-roledescription="slide" data-cascading-slide role="group" class="cascading-slider__item">
<div class="cascading-slider__item-inner">
<div class="cascading-slider__item-bg">
<img src="https://cdn.prod.website-files.com/699ecbb03f86e84bad7a74f3/699eea7d9bf91f87ca962997_cascading-carousel-1.avif" loading="lazy" draggable="false" class="cascading-slider__img">
</div>
<div class="cascading-slider__item-content">
<h3 class="cascading-slider__h">Infrastructure</h3>
</div>
</div>
</div>
<div aria-roledescription="slide" data-cascading-slide role="group" class="cascading-slider__item">
<div class="cascading-slider__item-inner">
<div class="cascading-slider__item-bg">
<img src="https://cdn.prod.website-files.com/699ecbb03f86e84bad7a74f3/699eea7d882b31c7ce3a35be_cascading-carousel-4.avif" loading="lazy" draggable="false" class="cascading-slider__img">
</div>
<div class="cascading-slider__item-content">
<h3 class="cascading-slider__h">Enterprises</h3>
</div>
</div>
</div>
</div>
</div>
<nav aria-label="slider navigation" class="cascading-slider__nav">
<button data-cascading-slider-prev aria-label="previous slide" class="cascading-slider__button">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="cascading-slider__button-arrow is--prev">
<path d="M14 19L21 12L14 5" stroke="currentColor" stroke-miterlimit="10" stroke-width="1.5"></path>
<path d="M21 12H2" stroke="currentColor" stroke-miterlimit="10" stroke-width="1.5"></path>
</svg>
</button>
<button data-cascading-slider-next aria-label="next slide" class="cascading-slider__button">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="cascading-slider__button-arrow">
<path d="M14 19L21 12L14 5" stroke="currentColor" stroke-miterlimit="10" stroke-width="1.5"></path>
<path d="M21 12H2" stroke="currentColor" stroke-miterlimit="10" stroke-width="1.5"></path>
</svg>
</button>
</nav>
</div>[data-cascading-viewport] {
--gap: 0.5em;
}
[data-cascading-slide] {
--clip: 0;
--radius: 0.75em;
}
.cascading-slider {
width: 100%;
max-width: 90em;
margin-left: auto;
margin-right: auto;
position: relative;
}
.cascading-slider__collection {
width: 100%;
}
.cascading-slider__list {
width: 100%;
height: 35em;
position: relative;
overflow: hidden;
}
.cascading-slider__item {
color: #fff;
cursor: pointer;
will-change: transform, clip-path;
clip-path: inset(0px calc(var(--clip) * 1px) round var(--radius));
-webkit-user-select: none;
user-select: none;
height: 100%;
position: absolute;
inset: 0% auto auto 0%;
}
.cascading-slider__item[data-status="active"] {
cursor: default;
}
.cascading-slider__item-inner {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.cascading-slider__item-bg {
z-index: 0;
position: absolute;
inset: 0%;
}
.cascading-slider__img {
object-fit: cover;
width: 100%;
height: 100%;
position: absolute;
inset: 0%;
}
.cascading-slider__item-content {
z-index: 2;
background-image: linear-gradient(0deg, #0009, #0000);
padding: 2em 2em 3em 2.5em;
position: absolute;
inset: auto 0% 0%;
}
.cascading-slider__h {
opacity: 0;
letter-spacing: -.03em;
margin-top: 0;
margin-bottom: 0;
font-size: 2.75em;
font-weight: 400;
line-height: 1;
transition: all .3s cubic-bezier(.645, .045, .355, 1);
transform: translate(0, .25em);
transition-delay: 0ms;
}
[data-cascading-slide][data-status="active"] .cascading-slider__h {
transition-delay: 400ms;
opacity: 1;
transform: translate(0px, 0em);
}
.cascading-slider__nav {
grid-column-gap: 1em;
grid-row-gap: 1em;
flex-flow: row;
justify-content: center;
align-items: center;
margin-top: 4em;
margin-left: auto;
margin-right: auto;
display: flex;
position: relative;
}
.cascading-slider__button {
color: #323b32;
background-color: #d7ecd7;
border-radius: .25em;
justify-content: center;
align-items: center;
width: 3em;
height: 3em;
padding: .75em;
display: flex;
}
.cascading-slider__button-arrow.is--prev {
transform: rotate(-180deg);
}function initCascadingSlider() {
const duration = 0.65;
const ease = 'power3.inOut';
const breakpoints = [
{ maxWidth: 479, activeWidth: 0.78, siblingWidth: 0.08 },
{ maxWidth: 767, activeWidth: 0.70, siblingWidth: 0.10 },
{ maxWidth: 991, activeWidth: 0.60, siblingWidth: 0.10 },
{ maxWidth: Infinity, activeWidth: 0.60, siblingWidth: 0.13 },
];
const wrappers = document.querySelectorAll('[data-cascading-slider-wrap]');
wrappers.forEach(setupInstance);
function setupInstance(wrapper) {
const viewport = wrapper.querySelector('[data-cascading-viewport]');
const prevButton = wrapper.querySelector('[data-cascading-slider-prev]');
const nextButton = wrapper.querySelector('[data-cascading-slider-next]');
const slides = Array.from(viewport.querySelectorAll('[data-cascading-slide]'));
let totalSlides = slides.length;
if (totalSlides === 0) return;
if (totalSlides < 9) {
const originalSlides = slides.slice();
while (slides.length < 9) {
originalSlides.forEach(function(original) {
const clone = original.cloneNode(true);
clone.setAttribute('data-clone', '');
viewport.appendChild(clone);
slides.push(clone);
});
}
totalSlides = slides.length;
}
let activeIndex = 0;
let isAnimating = false;
let slideWidth = 0;
let slotCenters = {};
let slotWidths = {};
function readGap() {
const raw = getComputedStyle(viewport).getPropertyValue('--gap').trim();
if (!raw) return 0;
const temp = document.createElement('div');
temp.style.width = raw;
temp.style.position = 'absolute';
temp.style.visibility = 'hidden';
viewport.appendChild(temp);
const px = temp.offsetWidth;
viewport.removeChild(temp);
return px;
}
function getSettings() {
const windowWidth = window.innerWidth;
for (let i = 0; i < breakpoints.length; i++) {
if (windowWidth <= breakpoints[i].maxWidth) return breakpoints[i];
}
return breakpoints[breakpoints.length - 1];
}
function getOffset(slideIndex, fromIndex) {
if (fromIndex === undefined) fromIndex = activeIndex;
let distance = slideIndex - fromIndex;
const half = totalSlides / 2;
if (distance > half) distance -= totalSlides;
if (distance < -half) distance += totalSlides;
return distance;
}
function measure() {
const settings = getSettings();
const viewportWidth = viewport.offsetWidth;
const gap = readGap();
const activeSlideWidth = viewportWidth * settings.activeWidth;
const siblingSlideWidth = viewportWidth * settings.siblingWidth;
const farSlideWidth = Math.max(0, (viewportWidth - activeSlideWidth - 2 * siblingSlideWidth - 4 * gap) / 2);
slideWidth = activeSlideWidth;
const visibleSlots = [
{ slot: -2, width: farSlideWidth },
{ slot: -1, width: siblingSlideWidth },
{ slot: 0, width: activeSlideWidth },
{ slot: 1, width: siblingSlideWidth },
{ slot: 2, width: farSlideWidth },
];
let x = 0;
visibleSlots.forEach(function(def, i) {
slotCenters[String(def.slot)] = x + def.width / 2;
slotWidths[String(def.slot)] = def.width;
if (i < visibleSlots.length - 1) x += def.width + gap;
});
slotCenters['-3'] = slotCenters['-2'] - farSlideWidth / 2 - gap - farSlideWidth / 2;
slotWidths['-3'] = farSlideWidth;
slotCenters['3'] = slotCenters['2'] + farSlideWidth / 2 + gap + farSlideWidth / 2;
slotWidths['3'] = farSlideWidth;
slides.forEach(function(slide) {
slide.style.width = slideWidth + 'px';
});
}
function getSlideProps(offset) {
const clamped = Math.max(-3, Math.min(3, offset));
const slotWidth = slotWidths[String(clamped)];
const clipAmount = Math.max(0, (slideWidth - slotWidth) / 2);
const translateX = slotCenters[String(clamped)] - slideWidth / 2;
return {
x: translateX,
'--clip': clipAmount,
zIndex: 10 - Math.abs(clamped),
};
}
function layout(animate, previousIndex) {
slides.forEach(function(slide, index) {
const offset = getOffset(index);
if (offset < -3 || offset > 3) {
if (animate && previousIndex !== undefined) {
const previousOffset = getOffset(index, previousIndex);
if (previousOffset >= -2 && previousOffset <= 2) {
const exitSlot = previousOffset < 0 ? -3 : 3;
gsap.to(slide, Object.assign({}, getSlideProps(exitSlot), {
duration, ease, overwrite: true,
}));
return;
}
}
const parkSlot = offset < 0 ? -3 : 3;
gsap.set(slide, getSlideProps(parkSlot));
return;
}
const props = getSlideProps(offset);
slide.setAttribute('data-status', offset === 0 ? 'active' : 'inactive');
if (animate) {
gsap.to(slide, Object.assign({}, props, { duration, ease, overwrite: true }));
} else {
gsap.set(slide, props);
}
});
}
function goTo(targetIndex) {
const normalizedTarget = ((targetIndex % totalSlides) + totalSlides) % totalSlides;
if (isAnimating || normalizedTarget === activeIndex) return;
isAnimating = true;
const previousIndex = activeIndex;
const travelDirection = getOffset(normalizedTarget, previousIndex) > 0 ? 1 : -1;
slides.forEach(function(slide, index) {
const currentOffset = getOffset(index, previousIndex);
const nextOffset = getOffset(index, normalizedTarget);
const wasInRange = currentOffset >= -3 && currentOffset <= 3;
const willBeVisible = nextOffset >= -2 && nextOffset <= 2;
if (!wasInRange && willBeVisible) {
const entrySlot = travelDirection > 0 ? 3 : -3;
gsap.set(slide, getSlideProps(entrySlot));
}
const wasInvisible = Math.abs(currentOffset) >= 3;
const willBeStaging = Math.abs(nextOffset) === 3;
const crossesSides = currentOffset * nextOffset < 0;
if (wasInvisible && willBeStaging && crossesSides) {
gsap.set(slide, getSlideProps(nextOffset > 0 ? 3 : -3));
}
});
activeIndex = normalizedTarget;
layout(true, previousIndex);
gsap.delayedCall(duration + 0.05, function() { isAnimating = false; });
}
if (prevButton) prevButton.addEventListener('click', function() { goTo(activeIndex - 1); });
if (nextButton) nextButton.addEventListener('click', function() { goTo(activeIndex + 1); });
slides.forEach(function(slide, index) {
slide.addEventListener('click', function() {
if (index !== activeIndex) goTo(index);
});
});
document.addEventListener('keydown', function(event) {
if (event.key === 'ArrowLeft') goTo(activeIndex - 1);
if (event.key === 'ArrowRight') goTo(activeIndex + 1);
});
let resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function() {
measure();
layout(false);
}, 100);
});
measure();
layout(false);
}
}
// Initialize Cascading Slider
document.addEventListener('DOMContentLoaded', function() {
initCascadingSlider();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| data-cascading-slider-wrap | boolean | Marks the outermost wrapper element. Must contain the viewport and navigation buttons. Multiple instances on the same page are supported. | |
| data-cascading-viewport | boolean | Marks the direct parent of all slides. Must have position: relative, overflow: hidden, and a defined height. The script reads --gap from this element. | |
| data-cascading-slide | boolean | Marks each slide element. Must be a direct child of the viewport. The script absolutely positions each slide and controls --clip via GSAP. | |
| data-cascading-slider-prev | boolean | Add to a button inside the wrapper to navigate to the previous slide. Optional. | |
| data-cascading-slider-next | boolean | Add to a button inside the wrapper to navigate to the next slide. Optional. | |
| data-status | string | Set automatically by the script on each slide. Values: active (current slide) or inactive (all others). Use for CSS styling. | |
| data-clone | boolean | Added automatically to duplicated slides when fewer than 9 slides are provided. Useful for targeting clones in CSS if needed. |
Notes
- •GSAP must be loaded before the script runs.
- •The slider requires at least 9 slides in the DOM for seamless looping. If fewer are provided, the script automatically duplicates full sets of original slides until the minimum is reached.
- •Height is set entirely in CSS on [data-cascading-viewport] — the script does not control height.
- •Gap is read from the --gap CSS variable on [data-cascading-viewport] and converted to pixels at runtime.
- •Clicking any visible (non-active) slide navigates to it. Arrow keys also navigate globally.
- •Width ratios per breakpoint are configured in the script's breakpoints array.
Guide
Wrapper
Use [data-cascading-slider-wrap] on the outermost element that contains both the viewport and the navigation controls.
<div data-cascading-slider-wrap>
<div data-cascading-viewport>...</div>
<button data-cascading-slider-prev>Prev</button>
<button data-cascading-slider-next>Next</button>
</div>Viewport
Use [data-cascading-viewport] on the direct container of all slides. This element must have position: relative, overflow: hidden, and a defined height. Set --gap here — the script reads it at runtime for positioning math.
Slide
Use [data-cascading-slide] on each slide element. Every slide must be a direct child of the viewport. Slides are absolutely positioned by the script and require the --clip CSS variable for the clip-path animation.
[data-cascading-slide] {
position: absolute;
top: 0;
left: 0;
height: 100%;
--clip: 0;
clip-path: inset(0px calc(var(--clip) * 1px) round var(--radius));
}Slide inner
Each slide needs an inner wrapper to contain your content. This element should have overflow: hidden.
<div data-cascading-slide>
<div class="slide-inner">
<!-- your content -->
</div>
</div>Slide border radius
Set --radius on [data-cascading-slide] or a parent element. The clip-path handles all rounding, so do not use border-radius directly on the slide.
Active state
The script sets [data-status="active"] on the current slide and [data-status="inactive"] on all others. Use this in CSS to style the active slide differently — for example to animate a heading.
Gap
Set --gap on [data-cascading-viewport] in CSS. The script reads this value and converts it to pixels for positioning math. You can use media queries to change the gap per breakpoint.
Breakpoints
Width ratios are configured in the script's breakpoints array. Each entry defines the active slide width and sibling width as fractions of the viewport. Far slides fill the remaining space automatically. Breakpoints are evaluated smallest to largest — first match wins.
const breakpoints = [
{ maxWidth: 479, activeWidth: 0.78, siblingWidth: 0.08 },
{ maxWidth: 767, activeWidth: 0.70, siblingWidth: 0.10 },
{ maxWidth: 991, activeWidth: 0.60, siblingWidth: 0.10 },
{ maxWidth: Infinity, activeWidth: 0.60, siblingWidth: 0.15 },
];Minimum slides
The slider requires at least 9 slides for seamless looping. If fewer are provided, the script clones full sets of the original slides until the minimum is reached. Clones receive [data-clone]. You can use this slider with as few as 3 slides.
Animation
Duration and easing are set at the top of the script. The ease value accepts any valid GSAP ease string.
const duration = 0.65;
const ease = 'power3.inOut';Multiple instances
The script queries all elements with [data-cascading-slider-wrap] and initialises each independently. Navigation, state, and resize handling are all scoped per instance.
Webflow CMS
When using a Collection List, apply [data-cascading-viewport] to the Collection List element itself. Each Collection Item gets [data-cascading-slide]. The Collection List Wrapper needs no attribute — just set its width to 100%.