SectionsAdvancedApril 9, 2026
Testimonial Slider
Infinite draggable testimonial carousel powered by GSAP's horizontalLoop utility. Supports autoplay with scroll-trigger pause/resume, inertia-based drag with snapping, bullet avatar navigation, prev/next controls, and an orange corner-bracket accent on the active slide.
View Full Demo →Preview
Source
demo.jsx
import TestimonialSlider from "./index.jsx";
import { testimonialSlider } from "@/content/sections/demo-data.js";
import styles from "./demo.module.css";
export default function TestimonialSliderDemo() {
return (
<div className={styles.demo}>
<div className={styles.heading}>
<p className={styles.label}>What clients say</p>
<h2 className={styles.title}>Kind words</h2>
</div>
<TestimonialSlider {...testimonialSlider} />
</div>
);
}
index.jsx
"use client";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { Draggable } from "gsap/Draggable";
import { CustomEase } from "gsap/CustomEase";
import { InertiaPlugin } from "gsap/InertiaPlugin";
import styles from "./styles.module.css";
gsap.registerPlugin(CustomEase, ScrollTrigger, Draggable, InertiaPlugin);
CustomEase.create("osmo-ease", "0.625, 0.05, 0, 1");
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;
}
export default function TestimonialSlider({
slides = [],
autoplay = true,
autoplayDuration = 4,
}) {
const wrapperRef = useRef(null);
const listRef = useRef(null);
useEffect(() => {
const wrapper = wrapperRef.current;
const list = listRef.current;
if (!wrapper || !list) return;
const slideEls = gsap.utils.toArray(list.querySelectorAll("[data-slide]"));
const bulletEls = gsap.utils.toArray(wrapper.querySelectorAll("[data-bullet]"));
const prevBtn = wrapper.querySelector("[data-prev]");
const nextBtn = wrapper.querySelector("[data-next]");
let activeSlide, activeBullet, currentIndex = 0;
let autoplayCall = null;
slideEls.forEach((slide, i) => slide.setAttribute("id", `slide-${i}`));
bulletEls.forEach((bullet, i) => {
bullet.setAttribute("aria-controls", `slide-${i}`);
bullet.setAttribute("aria-selected", i === 0 ? "true" : "false");
});
const loop = horizontalLoop(slideEls, {
paused: true,
draggable: true,
center: true,
onChange: (element, index) => {
currentIndex = index;
if (activeSlide) activeSlide.classList.remove(styles.active);
element.classList.add(styles.active);
activeSlide = element;
if (bulletEls.length) {
if (activeBullet) activeBullet.classList.remove(styles.active);
if (bulletEls[index]) {
bulletEls[index].classList.add(styles.active);
activeBullet = bulletEls[index];
}
bulletEls.forEach((b, i) =>
b.setAttribute("aria-selected", i === index ? "true" : "false")
);
}
},
});
loop.toIndex(2, { duration: 0.01 });
function startAutoplay() {
if (!autoplay || autoplayDuration <= 0 || autoplayCall) return;
const repeat = () => {
loop.next({ ease: "osmo-ease", duration: 0.725 });
autoplayCall = gsap.delayedCall(autoplayDuration, repeat);
};
autoplayCall = gsap.delayedCall(autoplayDuration, repeat);
}
function stopAutoplay() {
if (autoplayCall) { autoplayCall.kill(); autoplayCall = null; }
}
const st = ScrollTrigger.create({
trigger: wrapper,
start: "top bottom",
end: "bottom top",
onEnter: startAutoplay,
onLeave: stopAutoplay,
onEnterBack: startAutoplay,
onLeaveBack: stopAutoplay,
});
wrapper.addEventListener("mouseenter", stopAutoplay);
wrapper.addEventListener("mouseleave", () => {
if (ScrollTrigger.isInViewport(wrapper)) startAutoplay();
});
slideEls.forEach((slide, i) => {
slide.addEventListener("click", () =>
loop.toIndex(i, { ease: "osmo-ease", duration: 0.725 })
);
});
bulletEls.forEach((bullet, i) => {
bullet.addEventListener("click", () =>
loop.toIndex(i, { ease: "osmo-ease", duration: 0.725 })
);
});
if (prevBtn) {
prevBtn.addEventListener("click", () => {
let newIndex = currentIndex - 1;
if (newIndex < 0) newIndex = slideEls.length - 1;
loop.toIndex(newIndex, { ease: "osmo-ease", duration: 0.725 });
});
}
if (nextBtn) {
nextBtn.addEventListener("click", () => {
let newIndex = currentIndex + 1;
if (newIndex >= slideEls.length) newIndex = 0;
loop.toIndex(newIndex, { ease: "osmo-ease", duration: 0.725 });
});
}
return () => {
stopAutoplay();
st.kill();
loop?.kill?.();
};
}, [autoplay, autoplayDuration]);
return (
<div ref={wrapperRef} className={styles.wrapper} aria-label="Testimonial Slider">
{/* Bullet navigation */}
<div className={styles.controls}>
<ul role="tablist" className={styles.bulletList}>
{slides.map((slide, i) => (
<li key={i}>
<button
data-bullet=""
role="tab"
aria-selected="false"
className={styles.bullet}
>
<img
src={slide.avatarSrc}
alt={slide.name}
className={styles.bulletAvatar}
/>
</button>
</li>
))}
</ul>
</div>
{/* Slide row */}
<div className={styles.row}>
<div ref={listRef} role="group" aria-label="slides" className={styles.list}>
{slides.map((slide, i) => (
<div key={i} data-slide="" className={styles.slide}>
<div className={styles.slideInner}>
<p className={styles.quote}>{slide.quote}</p>
<div className={styles.details}>
<img
src={slide.avatarSrc}
alt={slide.name}
className={styles.avatar}
/>
<span className={styles.name}>{slide.name}</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Prev / Next buttons */}
<div className={styles.controls}>
<div className={styles.buttons}>
<button data-prev="" aria-label="previous slide" className={`${styles.button} ${styles.prev}`}>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 24 24" fill="none" className={styles.arrow}>
<path d="M14 19L21 12L14 5" stroke="currentColor" strokeMiterlimit="10" />
<path d="M21 12H2" stroke="currentColor" strokeMiterlimit="10" />
</svg>
</button>
<button data-next="" aria-label="next slide" className={styles.button}>
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 24 24" fill="none" className={styles.arrow}>
<path d="M14 19L21 12L14 5" stroke="currentColor" strokeMiterlimit="10" />
<path d="M21 12H2" stroke="currentColor" strokeMiterlimit="10" />
</svg>
</button>
</div>
</div>
</div>
);
}
demo.module.css
.demo {
min-height: 100vh;
background: #131313;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 5rem 0 4rem;
}
.heading {
text-align: center;
margin-bottom: 3rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.label {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.35);
}
.title {
font-size: clamp(2.5rem, 5vw, 5rem);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 0.95;
color: #efeeec;
}
styles.module.css
/* Local tokens */
.wrapper {
--space-s: 0.75rem;
--space-m: 1rem;
--space-l: 1.25rem;
--space-2xl: 2.5rem;
--space-5xl: 3rem;
--space-7xl: 5rem;
--font-body: 1rem;
--font-tagline: 0.75rem;
width: 100%;
}
/* Controls row */
.controls {
display: flex;
justify-content: center;
align-items: center;
}
/* Bullet navigation */
.bulletList {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: var(--space-s);
list-style: none;
margin: 0;
padding: 0;
}
.bullet {
position: relative;
width: 2em;
height: 2em;
padding: 0;
border-radius: 100em;
background-color: transparent;
border: none;
cursor: pointer;
overflow: hidden;
}
.bullet:focus {
outline: none;
}
.bullet::after {
content: "";
position: absolute;
inset: 2px;
border-radius: 100em;
z-index: -1;
border: 1px solid #ff4c24;
transition: inset 0.5s cubic-bezier(0.65, 0.05, 0, 1);
}
.bullet:hover::after,
.bullet.active::after,
.bullet:focus::after {
inset: -5px;
}
.bulletAvatar {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 100em;
display: block;
}
/* Slide row */
.row {
width: 100%;
margin-top: var(--space-2xl);
margin-bottom: var(--space-7xl);
padding-block: var(--space-m);
display: flex;
position: relative;
overflow: clip;
}
.list {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
width: 100%;
}
/* Slides */
.slide {
flex: none;
padding: var(--space-s);
transition: opacity 0.25s cubic-bezier(0.77, 0, 0.175, 1);
position: relative;
cursor: pointer;
}
.list:has(.active) .slide:not(.active) {
opacity: 0.45;
}
/* Corner accent on active slide */
.slide::after {
--corner-size: 1em;
--corner-width: 1px;
--corner-gap: 0.125em;
--corner-color: #ff4c24;
content: "";
position: absolute;
inset: calc(var(--corner-gap) * -1);
z-index: 1;
opacity: 0;
padding: calc(var(--corner-gap) + var(--corner-width));
outline: var(--corner-width) solid var(--corner-color);
outline-offset: calc(var(--corner-gap) / -1);
mask:
conic-gradient(
at var(--corner-size) var(--corner-size),
#0000 75%,
#000 0
)
0 0 / calc(100% - var(--corner-size)) calc(100% - var(--corner-size)),
linear-gradient(#000 0 0) content-box;
transition: all 0.4s cubic-bezier(0.65, 0.05, 0, 1);
pointer-events: none;
}
.slide.active::after {
outline-offset: calc(-1 * var(--corner-width));
opacity: 1;
}
.slideInner {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-5xl);
width: 30em;
min-height: 25em;
padding: var(--space-2xl);
border: 1px solid rgba(239, 238, 236, 0.1);
background-color: rgba(239, 238, 236, 0.1);
}
.quote {
font-size: 1.125rem;
line-height: 1.6;
color: #efeeec;
}
.details {
display: flex;
align-items: center;
gap: var(--space-s);
}
.avatar {
width: 2.5em;
height: 2.5em;
border-radius: 100em;
object-fit: cover;
flex-shrink: 0;
display: block;
}
.name {
font-size: var(--font-tagline);
letter-spacing: 0.08em;
text-transform: uppercase;
color: #efeeec;
}
/* Prev / Next buttons */
.buttons {
display: flex;
align-items: center;
gap: var(--space-m);
}
.button {
display: flex;
justify-content: center;
align-items: center;
width: 3em;
height: 3em;
padding: 0;
background-color: rgba(239, 238, 236, 0.1);
border: 1px solid rgba(239, 238, 236, 0.1);
border-radius: 0.25em;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
color: #efeeec;
}
.button:hover {
background-color: rgba(239, 238, 236, 0.2);
border-color: rgba(239, 238, 236, 0.25);
}
.prev {
transform: rotate(-180deg);
}
.arrow {
width: 1.25em;
}
@media (max-width: 479px) {
.slide { width: 85vw; }
.slideInner { width: 100%; }
}
Dependencies
gsap




