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.

gsapsplittexttestimonialsslideranimation

Setup — External Scripts

Setup: External Scripts
html
<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

index.html
html
<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>
styles.css
css
.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;
  }
}
script.js
javascript
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

NameTypeDefaultDescription
data-testimonial-wrapbooleanMarks the outer container for each slider instance. Must contain the list and navigation elements.
data-autoplaybooleanfalseSet to "true" to enable automatic slide advancement. Pauses when out of view and resumes when back in view.
data-autoplay-durationnumber4000Interval in milliseconds between automatic slide advances. Any user interaction resets the timer.
data-testimonial-listbooleanMarks the direct parent of all testimonial slides.
data-testimonial-itembooleanMarks each individual slide. Add the class is--active to the slide that should be visible on load.
data-testimonial-textbooleanMarks the primary text element within a slide. It is split into lines and animated one-by-one.
data-testimonial-splitbooleanMarks secondary text elements (e.g. name, title) that should also animate line-by-line.
data-testimonial-imgbooleanMarks an image element that animates with a circular clip-path reveal alongside the text. Optional.
data-prevbooleanAdd to a button to navigate to the previous slide. Resets autoplay when clicked.
data-nextbooleanAdd to a button to navigate to the next slide. Resets autoplay when clicked.
data-currentbooleanDisplays the current slide number. Updated automatically on every transition.
data-totalbooleanDisplays 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
}));