Centered Looping Slider
A centered infinite looping slider with draggable momentum, optional autoplay that pauses on hover and off-screen, bullet thumbnail navigation, prev/next buttons, and CSS active-state hooks for styling the focused slide.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/Draggable.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/InertiaPlugin.min.js"></script>Code
<div data-slider-autoplay-duration="4" aria-label="Testimonial Slider" data-centered-slider="wrapper" data-slider-autoplay="true" class="centered-slider-group">
<div class="container">
<div class="centered-slider-content">
<ul role="tablist" class="centered-slider-bullet__list">
<li class="centered-slider-bullet__item"><button data-centered-slider="bullet" role="tab" aria-selected="false" class="centered-slider-bullet"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a1ad3054186d3c6711_avatar-5.avif" alt=""></button></li>
<li class="centered-slider-bullet__item"><button data-centered-slider="bullet" role="tab" aria-selected="false" class="centered-slider-bullet"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a1af65ba866dc30020_avatar-2.avif" alt=""></button></li>
<li class="centered-slider-bullet__item"><button data-centered-slider="bullet" role="tab" aria-selected="false" class="centered-slider-bullet"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a1a74f278cb103f171_avatar-3.avif" alt=""></button></li>
<li class="centered-slider-bullet__item"><button data-centered-slider="bullet" role="tab" aria-selected="false" class="centered-slider-bullet"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a1014f1ec2c349acc8_avatar-6.avif" alt=""></button></li>
<li class="centered-slider-bullet__item"><button data-centered-slider="bullet" role="tab" aria-selected="false" class="centered-slider-bullet"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a154378639f0c3b8cb_avatar-4.avif" alt=""></button></li>
<li class="centered-slider-bullet__item"><button data-centered-slider="bullet" role="tab" aria-selected="false" class="centered-slider-bullet"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a1a49715653617490f_avatar-8.avif" alt=""></button></li>
<li class="centered-slider-bullet__item"><button data-centered-slider="bullet" role="tab" aria-selected="false" class="centered-slider-bullet"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a18cb9163202902407_avatar-1.avif" alt=""></button></li>
<li class="centered-slider-bullet__item"><button data-centered-slider="bullet" role="tab" aria-selected="false" class="centered-slider-bullet"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a088b02147174966b6_avatar-7.avif" alt=""></button></li>
</ul>
</div>
</div>
<div class="centered-slider-row">
<div aria-label="slides" data-centered-slider="list" role="group" class="centered-slider-list">
<div data-centered-slider="slide" class="centered-slider-slide">
<div class="centered-slider-slide__inner">
<p class="slide-demo__description">Osmo is my new go-to resource for the best Webflow cloneables and code snippets. It saves me a lot of time and elevates my workflow. The scaling system, in particular, is a game-changer—it's exactly what I was missing and is now my fluid scaling solution for every project.</p>
<div class="slide-demo__details"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a1ad3054186d3c6711_avatar-5.avif" alt="" class="slide-demo__avatar"><span class="slide-demo__eyebrow">Liam Bennett</span></div>
</div>
</div>
<div data-centered-slider="slide" class="centered-slider-slide">
<div class="centered-slider-slide__inner">
<p class="slide-demo__description">The Osmo Vault is a must-have for freelancers and agencies. It saves you a tremendous amount of time, delivers exceptional quality, and enhances creativity in your projects.</p>
<div class="slide-demo__details"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a1af65ba866dc30020_avatar-2.avif" alt="" class="slide-demo__avatar"><span class="slide-demo__eyebrow">Sophia Carter</span></div>
</div>
</div>
<div data-centered-slider="slide" class="centered-slider-slide">
<div class="centered-slider-slide__inner">
<p class="slide-demo__description">The creative developer's cheat code. Osmo is a one-stop shop, offering everything from snippets to help you set up your site to advanced animations and interactions that elevate it to the next level. The resources are so easy to implement, and with some imagination, you can adapt them to create something unique.</p>
<div class="slide-demo__details"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a1a74f278cb103f171_avatar-3.avif" alt="" class="slide-demo__avatar"><span class="slide-demo__eyebrow">Ethan Harper</span></div>
</div>
</div>
<div data-centered-slider="slide" class="centered-slider-slide">
<div class="centered-slider-slide__inner">
<p class="slide-demo__description">Osmo combines high-quality resources with intuitive guides, making the process of designing standout websites faster and easier, helping creatives to achieve great results in less time.</p>
<div class="slide-demo__details"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a1014f1ec2c349acc8_avatar-6.avif" alt="" class="slide-demo__avatar"><span class="slide-demo__eyebrow">Mia Reynolds</span></div>
</div>
</div>
<div data-centered-slider="slide" class="centered-slider-slide">
<div class="centered-slider-slide__inner">
<p class="slide-demo__description">One of a kind platform for any developers out there. It's incredible to be able to see and learn how the pros implement their animations. If you love web animations and creative development, this platform is a no brainer—just sign up already.</p>
<div class="slide-demo__details"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a154378639f0c3b8cb_avatar-4.avif" alt="" class="slide-demo__avatar"><span class="slide-demo__eyebrow">Noah Brooks</span></div>
</div>
</div>
<div data-centered-slider="slide" class="centered-slider-slide">
<div class="centered-slider-slide__inner">
<p class="slide-demo__description">Flawless UI—detailed, easy to implement, and straight-up reliable. The code is clean, well-explained, and ready to drop into Webflow without a hitch. You can tell it's built by pros. Love it and definitely using this on most of my projects. Osmo is the real deal.</p>
<div class="slide-demo__details"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a1a49715653617490f_avatar-8.avif" alt="" class="slide-demo__avatar"><span class="slide-demo__eyebrow">Olivia Porter</span></div>
</div>
</div>
<div data-centered-slider="slide" class="centered-slider-slide">
<div class="centered-slider-slide__inner">
<p class="slide-demo__description">Osmo is full of awesome (and easy to use) interactions that save so much time. They're visually powerful but also robust, and the best thing is, it's only going to get better as more even resources get added! Oh and it doesn't hurt that the dashboard looks sick too.</p>
<div class="slide-demo__details"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a18cb9163202902407_avatar-1.avif" alt="" class="slide-demo__avatar"><span class="slide-demo__eyebrow">Lucas Mitchell</span></div>
</div>
</div>
<div data-centered-slider="slide" class="centered-slider-slide">
<div class="centered-slider-slide__inner">
<p class="slide-demo__description">It's nice to get access to some creative dev best kept secrets - they're a great a source of inspiration for animations and interactions. Already found out some tricks for some issues that were giving me headaches before! Love how it explains the implementation rather than blindly copy-pasting it, making it much easier to customize.</p>
<div class="slide-demo__details"><img src="https://cdn.prod.website-files.com/67eeaec872efc86f0f0af614/67eec8a088b02147174966b6_avatar-7.avif" alt="" class="slide-demo__avatar"><span class="slide-demo__eyebrow">Ava Thompson</span></div>
</div>
</div>
</div>
</div>
<div class="container">
<div class="centered-slider-content">
<div class="centered-slider-buttons">
<button aria-label="previous slide" data-centered-slider="prev-button" class="centered-slider-button is--prev">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="slider-button-arrow">
<path d="M14 19L21 12L14 5" stroke="currentColor" stroke-miterlimit="10"></path>
<path d="M21 12H2" stroke="currentColor" stroke-miterlimit="10"></path>
</svg>
</button>
<button aria-label="next slide" data-centered-slider="next-button" class="centered-slider-button">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 24 24" fill="none" class="slider-button-arrow">
<path d="M14 19L21 12L14 5" stroke="currentColor" stroke-miterlimit="10"></path>
<path d="M21 12H2" stroke="currentColor" stroke-miterlimit="10"></path>
</svg>
</button>
</div>
</div>
</div>
</div>.centered-slider-row {
width: 100%;
margin-top: 2em;
margin-bottom: 4em;
padding-top: 1em;
padding-bottom: 1em;
display: flex;
position: relative;
overflow: clip;
}
.centered-slider-list {
flex-flow: row;
justify-content: flex-start;
align-items: center;
width: 100%;
display: flex;
}
.centered-slider-slide {
flex: none;
padding: 0.75em;
transition: opacity .25s cubic-bezier(.77, 0, .175, 1);
position: relative;
}
.centered-slider-slide__inner {
position: relative;
display: flex;
width: 21em;
padding-top: 1.25em;
padding-right: 1.25em;
padding-bottom: 1.25em;
padding-left: 1.25em;
flex-direction: column;
justify-content: flex-start;
flex-wrap: nowrap;
align-items: flex-start;
grid-column-gap: 3em;
grid-row-gap: 3em;
border: 1px solid #efeeec1a;
background-color: rgba(239, 238, 236, 0.1);
}
.centered-slider-row:has(.centered-slider-slide.active) .centered-slider-slide:not(.active) {
opacity: 0.45;
}
/* Corner-only border on active slide */
.centered-slider-slide::after {
--size: 1em;
--width: 1px;
--gap: 0.125em;
--color: #FF4C24;
content: '';
position: absolute;
inset: calc(var(--gap) * -1);
z-index: 1;
opacity: 0;
padding: calc(var(--gap) + var(--width));
outline: var(--width) solid var(--color);
outline-offset: calc(var(--gap) / -1);
mask:
conic-gradient(at var(--size) var(--size), #0000 75%, #000 0)
0 0 / calc(100% - var(--size)) calc(100% - var(--size)),
linear-gradient(#000 0 0) content-box;
transition: all 0.4s cubic-bezier(0.65, 0.05, 0, 1);
}
.centered-slider-slide.active::after {
outline-offset: calc(-1 * var(--width));
opacity: 1;
}
.slide-demo__details {
grid-column-gap: .75em;
grid-row-gap: .75em;
justify-content: flex-start;
align-items: center;
display: flex;
}
.slide-demo__avatar {
border-radius: 100em;
width: 2.5em;
height: 2.5em;
overflow: hidden;
}
.slide-demo__eyebrow {
text-transform: uppercase;
font-family: RM Mono, Arial, sans-serif;
font-size: .75em;
line-height: 1.2;
}
.centered-slider-content {
justify-content: center;
align-items: center;
margin-left: auto;
margin-right: auto;
display: flex;
}
.centered-slider-bullet__list {
grid-column-gap: .75em;
grid-row-gap: .75em;
flex-flow: wrap;
justify-content: center;
align-items: center;
margin-bottom: 0;
padding: 0;
list-style: none;
display: flex;
}
.centered-slider-bullet {
background-color: #0000;
border-radius: 100em;
width: 2em;
height: 2em;
padding: 0;
position: relative;
}
.centered-slider-bullet:focus {
border: none;
outline: none;
}
.centered-slider-bullet::after {
content: '';
position: absolute;
inset: 2px;
border-radius: 100em;
z-index: -1;
border: 1px solid #FF4C24;
transition: all 0.5s cubic-bezier(0.65, 0.05, 0, 1);
}
.centered-slider-bullet:hover::after,
.centered-slider-bullet.active::after,
.centered-slider-bullet:focus::after {
inset: -5px;
}
.centered-slider-buttons {
grid-column-gap: 1em;
grid-row-gap: 1em;
justify-content: center;
align-items: center;
display: flex;
}
.centered-slider-button {
background-color: #efeeec1a;
border: 1px solid #efeeec1a;
border-radius: .25em;
justify-content: center;
align-items: center;
width: 3em;
height: 3em;
padding: 0;
transition: border-color .2s, background-color .2s;
display: flex;
}
.centered-slider-button:hover {
background-color: #efeeec33;
border-color: #efeeec40;
}
.centered-slider-button.is--prev {
transform: rotate(-180deg);
}
.slider-button-arrow {
justify-content: center;
align-items: center;
width: 1.25em;
}
@media screen and (max-width: 479px) {
.centered-slider-slide {
width: 85vw;
}
.centered-slider-slide__inner {
width: 100%;
}
}gsap.registerPlugin(CustomEase, ScrollTrigger, Draggable, InertiaPlugin);
CustomEase.create("osmo-ease", "0.625, 0.05, 0, 1");
function initSliders() {
const sliderWrappers = gsap.utils.toArray(document.querySelectorAll('[data-centered-slider="wrapper"]'));
sliderWrappers.forEach((sliderWrapper) => {
const slides = gsap.utils.toArray(sliderWrapper.querySelectorAll('[data-centered-slider="slide"]'));
const bullets = gsap.utils.toArray(sliderWrapper.querySelectorAll('[data-centered-slider="bullet"]'));
const prevButton = sliderWrapper.querySelector('[data-centered-slider="prev-button"]');
const nextButton = sliderWrapper.querySelector('[data-centered-slider="next-button"]');
let activeElement;
let activeBullet;
let currentIndex = 0;
let autoplay;
const autoplayEnabled = sliderWrapper.getAttribute('data-slider-autoplay') === 'true';
const autoplayDuration = autoplayEnabled
? parseFloat(sliderWrapper.getAttribute('data-slider-autoplay-duration')) || 0
: 0;
// Dynamically assign unique IDs to slides
slides.forEach((slide, i) => {
slide.setAttribute("id", `slide-${i}`);
});
// Set ARIA attributes on bullets if they exist
if (bullets && bullets.length > 0) {
bullets.forEach((bullet, i) => {
bullet.setAttribute("aria-controls", `slide-${i}`);
bullet.setAttribute("aria-selected", i === currentIndex ? "true" : "false");
});
}
const loop = horizontalLoop(slides, {
paused: true,
draggable: true,
center: true,
onChange: (element, index) => {
currentIndex = index;
if (activeElement) activeElement.classList.remove("active");
element.classList.add("active");
activeElement = element;
if (bullets && bullets.length > 0) {
if (activeBullet) activeBullet.classList.remove("active");
if (bullets[index]) {
bullets[index].classList.add("active");
activeBullet = bullets[index];
}
bullets.forEach((bullet, i) => {
bullet.setAttribute("aria-selected", i === index ? "true" : "false");
});
}
}
});
// On initialization, center the slider
loop.toIndex(2, { duration: 0.01 });
function startAutoplay() {
if (autoplayDuration > 0 && !autoplay) {
const repeat = () => {
loop.next({ ease: "osmo-ease", duration: 0.725 });
autoplay = gsap.delayedCall(autoplayDuration, repeat);
};
autoplay = gsap.delayedCall(autoplayDuration, repeat);
}
}
function stopAutoplay() {
if (autoplay) {
autoplay.kill();
autoplay = null;
}
}
// Start/stop autoplay based on viewport visibility via ScrollTrigger
ScrollTrigger.create({
trigger: sliderWrapper,
start: "top bottom",
end: "bottom top",
onEnter: startAutoplay,
onLeave: stopAutoplay,
onEnterBack: startAutoplay,
onLeaveBack: stopAutoplay
});
// Pause autoplay on mouse hover over the slider
sliderWrapper.addEventListener("mouseenter", stopAutoplay);
sliderWrapper.addEventListener("mouseleave", () => {
if (ScrollTrigger.isInViewport(sliderWrapper)) startAutoplay();
});
// Slide click event for direct navigation
slides.forEach((slide, i) => {
slide.addEventListener("click", () => {
loop.toIndex(i, { ease: "osmo-ease", duration: 0.725 });
});
});
// Bullets click event for direct navigation (if available)
if (bullets && bullets.length > 0) {
bullets.forEach((bullet, i) => {
bullet.addEventListener("click", () => {
loop.toIndex(i, { ease: "osmo-ease", duration: 0.725 });
if (activeBullet) activeBullet.classList.remove("active");
bullet.classList.add("active");
activeBullet = bullet;
bullets.forEach((b, j) => {
b.setAttribute("aria-selected", j === i ? "true" : "false");
});
});
});
}
// Prev/Next button listeners
if (prevButton) {
prevButton.addEventListener("click", () => {
let newIndex = currentIndex - 1;
if (newIndex < 0) newIndex = slides.length - 1;
loop.toIndex(newIndex, { ease: "osmo-ease", duration: 0.725 });
});
}
if (nextButton) {
nextButton.addEventListener("click", () => {
let newIndex = currentIndex + 1;
if (newIndex >= slides.length) newIndex = 0;
loop.toIndex(newIndex, { ease: "osmo-ease", duration: 0.725 });
});
}
});
}
document.addEventListener("DOMContentLoaded", () => {
initSliders();
});
// GSAP Helper function to create a looping slider
// Read more: https://gsap.com/docs/v3/HelperFunctions/helpers/seamlessLoop
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;
}Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| data-centered-slider="wrapper" | string | "" | Add to the outermost container to initialise a slider instance. Autoplay and duration are configured via additional attributes on this element. |
| data-slider-autoplay | "true" | "false" | "false" | Set to "true" on the wrapper to enable automatic slide advancement. Autoplay is paused when the slider is hovered or scrolled out of view, and resumed when it re-enters. |
| data-slider-autoplay-duration | number | 4 | Sets the number of seconds between automatic slide advances when autoplay is enabled. |
| data-centered-slider="list" | string | "" | Add to the flex container holding all slides. This is the Draggable trigger element and the element the horizontalLoop helper measures. |
| data-centered-slider="slide" | string | "" | Add to each slide element. The script assigns a unique id (slide-0, slide-1, …) and the .active CSS class to the current slide. |
| data-centered-slider="bullet" | string | "" | Add to each bullet navigation button. The script assigns aria-controls, aria-selected, and the .active CSS class. Bullets are optional — the slider works without them. |
| data-centered-slider="prev-button" | string | "" | Add to the previous navigation button. Optional — clicking navigates to the previous slide, wrapping around to the last slide. |
| data-centered-slider="next-button" | string | "" | Add to the next navigation button. Optional — clicking navigates to the next slide, wrapping around to the first slide. |
Notes
- •The horizontalLoop helper is a GSAP utility function from the official GSAP docs. It creates a seamless infinite loop by animating xPercent on each slide within a repeating GSAP timeline.
- •The loop.toIndex(2, { duration: 0.01 }) call on initialisation instantly centres the third slide — adjust the index or use 0 to start at the first slide.
- •Autoplay uses gsap.delayedCall recursively so it can be trivially stopped by killing the single delayed call reference, without managing intervals or timeouts.
- •ScrollTrigger watches the wrapper element; autoplay starts when the slider enters the viewport and stops when it leaves, preventing background animation on long pages.
- •Hover over the wrapper also pauses autoplay. On mouseleave it checks ScrollTrigger.isInViewport before restarting so it does not resume if the user has scrolled away.
- •The active slide receives the .active CSS class. The provided CSS uses a :has() selector to reduce opacity on all non-active siblings — a pure CSS approach with no JS opacity tweening.
- •The corner-bracket active indicator on each slide is drawn entirely in CSS using a mask with conic-gradient and linear-gradient — no extra elements or SVGs are needed.
Guide
Wrapper & slides
Add data-centered-slider="wrapper" to the outer container and data-centered-slider="slide" to each slide. The list container (data-centered-slider="list") must be a direct flex parent of all slides.
Autoplay
Enable autoplay by adding data-slider-autoplay="true" and data-slider-autoplay-duration="4" (seconds) to the wrapper. Autoplay automatically pauses on hover and when the slider is scrolled out of view.
Bullet navigation
Add a <ul role="tablist"> above the slides containing <button data-centered-slider="bullet"> elements — one per slide, in the same order. The script syncs .active class and aria-selected automatically. Bullets are optional.
Prev/Next buttons
Add any two buttons with data-centered-slider="prev-button" and data-centered-slider="next-button" anywhere inside the wrapper. Both wrap around when the slider reaches either end. Both are optional.
Styling the active slide
The .active class is added to the current slide on every navigation event. Use it in CSS to highlight the active slide and fade others with :not(.active).
.centered-slider-row:has(.centered-slider-slide.active) .centered-slider-slide:not(.active) {
opacity: 0.45;
}Starting slide
loop.toIndex(2, { duration: 0.01 }) centres slide index 2 on load. Change the index to start at a different slide, or pass duration: 0 for an instant jump with no animation.
Drag & inertia
Draggable and InertiaPlugin are used by the horizontalLoop helper to handle drag and throw gestures directly on the slide list. No additional configuration is needed — they are wired up inside the helper function.