Line Reveal Testimonials
A testimonial slider that animates text line-by-line with a clip-path image reveal. Supports autoplay with viewport-aware pause/resume, keyboard navigation, reduced-motion fallback, and multiple instances per page.
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/SplitText.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>Code
<div data-testimonial-wrap="" data-autoplay="true" data-autoplay-duration="5000" class="testimonial-lines">
<div class="testimonial-lines__controls">
<button data-prev="" aria-label="previous testimonial" class="testimonial-lines__button">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 12 12" fill="none" class="testimonial-lines__arrow">
<path d="M5.26512 12L6.43721 10.7746L1.48837 5.28169V6.71831L6.45581 1.22535L5.28372 0L-2.21369e-07 6L5.26512 12ZM12 6.97183V5.02817H1.30232V6.97183H12Z" fill="currentColor"></path>
</svg>
</button>
<button data-next="" aria-label="next testimonial" class="testimonial-lines__button">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 12 12" fill="none" class="testimonial-lines__arrow">
<path d="M6.73488 12L5.56279 10.7746L10.5116 5.28169V6.71831L5.54419 1.22535L6.71628 0L12 6L6.73488 12ZM0 6.97183V5.02817H10.6977V6.97183H0Z" fill="currentColor"></path>
</svg>
</button>
</div>
<div class="testimonial-lines__main">
<div class="testimonial-lines__main-details">
<p class="testimonial-lines__p is--faded"><span data-current="" class="testimonial-lines__count">1</span> / <span data-total="">5</span></p>
<p class="testimonial-lines__p">What our clients say:</p>
</div>
<div class="testimonial-lines__collection">
<div role="list" data-testimonial-list="" class="testimonial-lines__list">
<div aria-hidden="false" data-testimonial-item="" role="listitem" class="testimonial-lines__item is--active">
<h3 data-testimonial-text="" class="testimonial-lines__h">"After a rough quarter, we needed hands fast. Their team jumped in with clear pricing and flexible coverage for weekend rushes and supplier delays. They've become our first call when operations get tight."</h3>
<div class="testimonial-lines__item-details">
<div data-testimonial-img="" class="testimonial-lines__item-visual">
<img src="https://cdn.prod.website-files.com/697946f9c74d6e83502491c6/6979fcf493ed513c80fb67b0_img-1.avif" class="testimonial-lines__item-img">
</div>
<div>
<p data-testimonial-split="" class="testimonial-lines__p">Mara Kline</p>
<p data-testimonial-split="" class="testimonial-lines__p is--faded">Northbay Produce Co.</p>
</div>
</div>
</div>
<div aria-hidden="true" data-testimonial-item="" role="listitem" class="testimonial-lines__item">
<h3 data-testimonial-text="" class="testimonial-lines__h">"We were referred by a partner and liked the straight answers. They helped us stabilize scheduling, fill last-minute gaps, and keep deliveries on time during peak season. Now we reach out before problems snowball."</h3>
<div class="testimonial-lines__item-details">
<div data-testimonial-img="" class="testimonial-lines__item-visual">
<img src="https://cdn.prod.website-files.com/697946f9c74d6e83502491c6/6979fcf4340be6c44df0fa86_img-2.avif" class="testimonial-lines__item-img">
</div>
<div>
<p data-testimonial-split="" class="testimonial-lines__p">Devon Reyes</p>
<p data-testimonial-split="" class="testimonial-lines__p is--faded">Kestrel Courier Group</p>
</div>
</div>
</div>
<div aria-hidden="true" data-testimonial-item="" role="listitem" class="testimonial-lines__item">
<h3 data-testimonial-text="" class="testimonial-lines__h">"During our expansion, training and onboarding fell behind. They stepped in with consistent staffing, fair rates, and quick turnaround for urgent shifts."</h3>
<div class="testimonial-lines__item-details">
<div data-testimonial-img="" class="testimonial-lines__item-visual">
<img src="https://cdn.prod.website-files.com/697946f9c74d6e83502491c6/6979fcf41e2c7bc67c213c31_img-3.avif" class="testimonial-lines__item-img">
</div>
<div>
<p data-testimonial-split="" class="testimonial-lines__p">Priya Menon</p>
<p data-testimonial-split="" class="testimonial-lines__p is--faded">Harborview Senior Living</p>
</div>
</div>
</div>
<div aria-hidden="true" data-testimonial-item="" role="listitem" class="testimonial-lines__item">
<h3 data-testimonial-text="" class="testimonial-lines__h">"We had a sudden equipment outage and couldn't afford downtime. They coordinated extra coverage, kept communication simple, and helped us meet our production commitments without surprises."</h3>
<div class="testimonial-lines__item-details">
<div data-testimonial-img="" class="testimonial-lines__item-visual">
<img src="https://cdn.prod.website-files.com/697946f9c74d6e83502491c6/6979fcf45f6f900cd99e3701_img-4.avif" class="testimonial-lines__item-img">
</div>
<div>
<p data-testimonial-split="" class="testimonial-lines__p">Cole Hart</p>
<p data-testimonial-split="" class="testimonial-lines__p is--faded">Redstone Bottling Works</p>
</div>
</div>
</div>
<div aria-hidden="true" data-testimonial-item="" role="listitem" class="testimonial-lines__item">
<h3 data-testimonial-text="" class="testimonial-lines__h">"Our busiest months are unpredictable, and hiring temp help is usually a headache. They made it easy—clear terms, flexible availability, and people who actually showed up prepared. They're our go-to when demand spikes."</h3>
<div class="testimonial-lines__item-details">
<div data-testimonial-img="" class="testimonial-lines__item-visual">
<img src="https://cdn.prod.website-files.com/697946f9c74d6e83502491c6/6979fcf4cb1e0ab479d5809a_img-5.avif" class="testimonial-lines__item-img">
</div>
<div>
<p data-testimonial-split="" class="testimonial-lines__p">Lina Okafor</p>
<p data-testimonial-split="" class="testimonial-lines__p is--faded">Juniper Street Catering</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>.testimonial-lines {
grid-column-gap: 1.25em;
grid-row-gap: 1.25em;
flex-flow: wrap;
justify-content: flex-start;
align-items: flex-start;
display: flex;
}
.testimonial-lines__controls {
grid-column-gap: 1em;
grid-row-gap: 1em;
flex-flow: row;
justify-content: flex-start;
align-items: flex-start;
width: 33.3333%;
display: flex;
}
.testimonial-lines__main {
grid-column-gap: 5em;
grid-row-gap: 5em;
flex-flow: column;
flex: 1;
justify-content: flex-start;
align-items: flex-start;
display: flex;
}
.testimonial-lines__button {
background-color: #0000;
border: 1px solid #0003;
border-radius: .25em;
justify-content: center;
align-items: center;
width: 2.5em;
height: 2.5em;
padding: 0;
display: flex;
}
.testimonial-lines__arrow {
width: .75em;
}
.testimonial-lines__main-details {
grid-column-gap: 1.5em;
grid-row-gap: 1.5em;
flex-flow: row;
justify-content: flex-start;
align-items: center;
display: flex;
}
.testimonial-lines__count {
width: 1ch;
display: inline-block;
}
.testimonial-lines__p {
margin-bottom: 0;
font-size: 1.25em;
line-height: 1.2;
}
.testimonial-lines__p.is--faded {
opacity: .5;
}
.testimonial-lines__collection {
width: 100%;
}
.testimonial-lines__list {
width: 100%;
display: grid;
position: relative;
}
.testimonial-lines__item {
grid-column-gap: 4em;
grid-row-gap: 4em;
opacity: 0;
visibility: hidden;
flex-flow: column;
grid-area: 1 / 1;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
display: flex;
position: relative;
}
.testimonial-lines__item.is--active {
opacity: 100;
visibility: visible;
}
.testimonial-lines__h {
letter-spacing: -.02em;
width: 100%;
margin-top: 0;
margin-bottom: 0;
font-size: 3em;
font-weight: 500;
line-height: 1;
}
.text-line-mask {
padding-bottom: 0.2em;
margin-bottom: -0.2em;
}
.testimonial-lines__item-details {
grid-column-gap: 1.25em;
grid-row-gap: 1.25em;
flex-flow: row;
justify-content: flex-start;
align-items: center;
display: flex;
}
.testimonial-lines__item-visual {
aspect-ratio: 1;
border-radius: 100em;
width: 5em;
overflow: hidden;
}
.testimonial-lines__item-img {
object-fit: cover;
width: 100%;
height: 100%;
}
@media screen and (max-width: 767px) {
.testimonial-lines {
grid-column-gap: 3em;
grid-row-gap: 3em;
}
.testimonial-lines__controls {
order: 9999;
width: 100%;
}
.testimonial-lines__main {
grid-column-gap: 3em;
grid-row-gap: 3em;
}
.testimonial-lines__p {
font-size: 1em;
}
.testimonial-lines__item {
grid-column-gap: 2em;
grid-row-gap: 2em;
}
.testimonial-lines__h {
font-size: 2em;
}
.testimonial-lines__item-visual {
width: 3.5em;
}
}function initLineRevealTestimonials() {
const wraps = document.querySelectorAll("[data-testimonial-wrap]");
if (!wraps.length) return;
const imageClipHidden = "circle(0% at 50% 50%)";
const imageClipVisible = "circle(50% at 50% 50%)";
wraps.forEach((wrap) => {
const list = wrap.querySelector("[data-testimonial-list]");
if (!list) return;
const items = Array.from(list.querySelectorAll("[data-testimonial-item]"));
if (!items.length) return;
const btnPrev = wrap.querySelector("[data-prev]");
const btnNext = wrap.querySelector("[data-next]");
const elCurrent = wrap.querySelector("[data-current]");
const elTotal = wrap.querySelector("[data-total]");
if (elTotal) elTotal.textContent = String(items.length);
let activeIndex = items.findIndex((el) => el.classList.contains("is--active"));
if (activeIndex < 0) activeIndex = 0;
let isAnimating = false;
let reduceMotion = false;
const autoplayEnabled = wrap.getAttribute("data-autoplay") === "true";
const autoplayDuration = parseInt(wrap.getAttribute("data-autoplay-duration"), 10) || 4000;
let autoplayCall = null;
let isInView = true;
const slides = items.map((item) => ({
item,
image: item.querySelector("[data-testimonial-img]"),
splitTargets: [
item.querySelector("[data-testimonial-text]"),
...item.querySelectorAll("[data-testimonial-split]"),
].filter(Boolean),
splitInstances: [],
getLines() {
return this.splitInstances.flatMap((instance) => instance.lines);
},
}));
function setSlideState(slideIndex, isActive) {
const { item } = slides[slideIndex];
item.classList.toggle("is--active", isActive);
item.setAttribute("aria-hidden", String(!isActive));
gsap.set(item, {
autoAlpha: isActive ? 1 : 0,
pointerEvents: isActive ? "auto" : "none",
});
}
function updateCounter() {
if (elCurrent) elCurrent.textContent = String(activeIndex + 1);
}
function startAutoplay() {
if (!autoplayEnabled) return;
if (autoplayCall) autoplayCall.kill();
autoplayCall = gsap.delayedCall(autoplayDuration / 1000, () => {
if (!isInView || isAnimating) { startAutoplay(); return; }
goTo((activeIndex + 1) % slides.length);
startAutoplay();
});
}
function pauseAutoplay() { if (autoplayCall) autoplayCall.pause(); }
function resumeAutoplay() {
if (!autoplayEnabled) return;
if (!autoplayCall) startAutoplay();
else autoplayCall.resume();
}
function resetAutoplay() { if (autoplayEnabled) startAutoplay(); }
// Set initial state
slides.forEach((_, i) => setSlideState(i, i === activeIndex));
updateCounter();
gsap.matchMedia().add(
{ reduce: "(prefers-reduced-motion: reduce)" },
(context) => { reduceMotion = context.conditions.reduce; }
);
// Create SplitText instances
slides.forEach((slide, slideIndex) => {
slide.splitInstances = slide.splitTargets.map((el) =>
SplitText.create(el, {
type: "lines",
mask: "lines",
linesClass: "text-line",
autoSplit: true,
onSplit(self) {
if (reduceMotion) return;
const isActive = slideIndex === activeIndex;
gsap.set(self.lines, { yPercent: isActive ? 0 : 110 });
if (slide.image) {
gsap.set(slide.image, {
clipPath: isActive ? imageClipVisible : imageClipHidden,
});
}
},
})
);
});
function goTo(nextIndex) {
if (isAnimating || nextIndex === activeIndex) return;
isAnimating = true;
const outgoingSlide = slides[activeIndex];
const incomingSlide = slides[nextIndex];
const tl = gsap.timeline({
onComplete: () => {
setSlideState(activeIndex, false);
setSlideState(nextIndex, true);
activeIndex = nextIndex;
updateCounter();
isAnimating = false;
},
});
if (reduceMotion) {
tl.to(outgoingSlide.item, { autoAlpha: 0, duration: 0.4, ease: "power2" }, 0)
.fromTo(incomingSlide.item, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.4, ease: "power2" }, 0);
return;
}
const outgoingLines = outgoingSlide.getLines();
const incomingLines = incomingSlide.getLines();
gsap.set(incomingSlide.item, { autoAlpha: 1, pointerEvents: "auto" });
gsap.set(incomingLines, { yPercent: 110 });
if (outgoingSlide.image) gsap.set(outgoingSlide.image, { clipPath: imageClipVisible });
tl.to(outgoingLines, {
yPercent: -110,
duration: 0.6,
ease: "power4.inOut",
stagger: { amount: 0.25 },
}, 0);
if (outgoingSlide.image) {
tl.to(outgoingSlide.image, {
clipPath: imageClipHidden,
duration: 0.6,
ease: "power4.inOut",
}, 0);
}
tl.to(incomingLines, {
yPercent: 0,
duration: 0.7,
ease: "power4.inOut",
stagger: { amount: 0.4 },
}, ">-=0.3");
if (incomingSlide.image) {
tl.fromTo(incomingSlide.image, {
clipPath: imageClipHidden,
}, {
clipPath: imageClipVisible,
duration: 0.75,
ease: "power4.inOut",
}, "<");
}
tl.set(outgoingSlide.item, { autoAlpha: 0 }, ">");
}
startAutoplay();
if (btnNext) {
btnNext.addEventListener("click", () => {
resetAutoplay();
goTo((activeIndex + 1) % slides.length);
});
}
if (btnPrev) {
btnPrev.addEventListener("click", () => {
resetAutoplay();
goTo((activeIndex - 1 + slides.length) % slides.length);
});
}
function onKeyDown(e) {
if (!isInView) return;
const t = e.target;
const isTypingTarget = t && (
t.tagName === "INPUT" ||
t.tagName === "TEXTAREA" ||
t.isContentEditable
);
if (isTypingTarget) return;
if (e.key === "ArrowRight") { e.preventDefault(); resetAutoplay(); goTo((activeIndex + 1) % slides.length); }
if (e.key === "ArrowLeft") { e.preventDefault(); resetAutoplay(); goTo((activeIndex - 1 + slides.length) % slides.length); }
}
window.addEventListener("keydown", onKeyDown);
ScrollTrigger.create({
trigger: wrap,
start: "top bottom",
end: "bottom top",
onEnter: () => { isInView = true; resumeAutoplay(); },
onEnterBack: () => { isInView = true; resumeAutoplay(); },
onLeave: () => { isInView = false; pauseAutoplay(); },
onLeaveBack: () => { isInView = false; pauseAutoplay(); },
});
});
}
// Initialize Line Reveal Testimonials
document.addEventListener("DOMContentLoaded", () => {
initLineRevealTestimonials();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| data-testimonial-wrap | boolean | Marks the outer container for each slider instance. Must contain the list and navigation elements. | |
| data-autoplay | boolean | false | Set to "true" to enable automatic slide advancement. Pauses when out of view and resumes when back in view. |
| data-autoplay-duration | number | 4000 | Interval in milliseconds between automatic slide advances. Any user interaction resets the timer. |
| data-testimonial-list | boolean | Marks the direct parent of all testimonial slides. | |
| data-testimonial-item | boolean | Marks each individual slide. Add the class is--active to the slide that should be visible on load. | |
| data-testimonial-text | boolean | Marks the primary text element within a slide. It is split into lines and animated one-by-one. | |
| data-testimonial-split | boolean | Marks secondary text elements (e.g. name, title) that should also animate line-by-line. | |
| data-testimonial-img | boolean | Marks an image element that animates with a circular clip-path reveal alongside the text. Optional. | |
| data-prev | boolean | Add to a button to navigate to the previous slide. Resets autoplay when clicked. | |
| data-next | boolean | Add to a button to navigate to the next slide. Resets autoplay when clicked. | |
| data-current | boolean | Displays the current slide number. Updated automatically on every transition. | |
| data-total | boolean | Displays the total number of slides. Set automatically on initialisation. |
Notes
- •GSAP, SplitText, and ScrollTrigger must all be loaded before the script runs.
- •SplitText is a GSAP Club plugin — it requires a GSAP Club or Business membership.
- •Add the class is--active to the first slide you want visible on load.
- •Autoplay pauses automatically when the slider scrolls out of view and resumes when it returns.
- •When the user prefers reduced motion, the line animation and image reveal are replaced with a simple crossfade.
- •Keyboard arrow navigation is automatically ignored when focus is inside an input, textarea, or contenteditable element.
Guide
Wrap
The outer container [data-testimonial-wrap] defines the boundary for each slider instance. Set data-autoplay and data-autoplay-duration here.
List
Inside the wrap, [data-testimonial-list] holds all the individual testimonial slides and manages their stacking via CSS grid.
Items / slides
Each slide requires [data-testimonial-item]. Add the class is--active to whichever slide should be visible on load. All others start hidden.
Main text element
The primary quote text uses [data-testimonial-text]. SplitText splits it into lines, each wrapped in a mask, and GSAP animates each line in or out.
Smaller text elements
Add [data-testimonial-split] to secondary text elements such as the author name or job title. These are also split and animated line-by-line.
Image
An element with [data-testimonial-img] animates with a circular clip-path reveal (circle(0%) → circle(50%)) in sync with the text. This is optional and the clip-path values can be changed freely in the script.
Navigation
Use [data-prev] and [data-next] on buttons inside the wrap. Both reset the autoplay timer on click. Arrow keys also navigate when the slider is in view.
Counter
Place [data-current] and [data-total] anywhere inside the wrap. [data-total] is set on init and [data-current] updates on every transition.
Autoplay
Set data-autoplay="true" on the wrap and control the interval with data-autoplay-duration (milliseconds). Any navigation resets the timer. Autoplay pauses via ScrollTrigger when out of view and resumes on re-entry.
Keyboard navigation
Left and right arrow keys navigate the slider when it is in view. Navigation is ignored while focus is inside a form field to avoid conflicting with user input.
Accessibility & reduced motion
Each slide receives aria-hidden based on its active state. When the user prefers reduced motion, line-by-line animation and the image clip-path reveal are replaced with a crossfade transition.
Extending slide data
To add new animated elements, extend the slides map with your element query, set its initial state inside the onSplit callback, and add corresponding animate-in / animate-out steps to the goTo timeline.
const slides = items.map((item) => ({
item,
image: item.querySelector("[data-testimonial-img]"),
icon: item.querySelector("[data-testimonial-icon]"), // new element
// ...rest of the existing map
}));