# Line Reveal Testimonials

> Testimonials that emerge line by line with elegant timing — trust-building quotes with quiet confidence.

**Definition:** Line Reveal Testimonials is a text UI animation from collections.html. It is built with HTML, CSS, JavaScript, and GSAP, ships with a live preview, and is free to copy into personal and commercial projects.

## Facts
- Category: Text
- Stack: HTML, CSS, JavaScript, and GSAP
- Added: 2026-08-01
- Canonical page: https://html.bosconvertpro.com/collection/line-reveal-testimonials
- Full-page preview: https://html.bosconvertpro.com/collection/line-reveal-testimonials/full
- Live demo: https://html.bosconvertpro.com/demos/line-reveal-testimonials/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/line-reveal-testimonials/index.md
- License: https://html.bosconvertpro.com/terms

## How to use
1. Open https://html.bosconvertpro.com/collection/line-reveal-testimonials/full to preview the animation in full screen.
2. Copy index.html, styles.css, and script.js from the code tabs on https://html.bosconvertpro.com/collection/line-reveal-testimonials.
3. Paste the files into your project and adjust timing, easing, and content as needed.

## FAQ
### What is Line Reveal Testimonials?

Line Reveal Testimonials is a text UI animation from collections.html. Testimonials that emerge line by line with elegant timing — trust-building quotes with quiet confidence.

### What is Line Reveal Testimonials built with?

Line Reveal Testimonials is built with HTML, CSS, JavaScript, and GSAP. The source for every file is shown on the animation page and can be copied straight into your project.

### How do I use Line Reveal Testimonials in my project?

Open the animation page (https://html.bosconvertpro.com/collection/line-reveal-testimonials), preview it live, then copy index.html, styles.css, and script.js from the code tabs. Paste the files into your project and adjust timing, easing, and content as needed. A standalone demo runs at https://html.bosconvertpro.com/demos/line-reveal-testimonials/index.html.

### Is Line Reveal Testimonials free to use?

Yes. The Line Reveal Testimonials demo code may be used in personal and commercial projects. You may not resell or redistribute the collection itself as a competing template pack. Full terms: https://html.bosconvertpro.com/terms.

## Source — index.html
````html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Line Reveal Testimonials</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link rel="preconnect" href="https://images.unsplash.com" />
    <link
      href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div class="page">
      <div class="page__glow" aria-hidden="true"></div>
      <div class="page__grain" aria-hidden="true"></div>

      <div
        data-testimonial-wrap
        data-autoplay="true"
        data-autoplay-duration="5000"
        class="testimonial-lines"
      >
        <div class="testimonial-lines__controls" data-chrome>
          <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"
              aria-hidden="true"
            >
              <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"
              />
            </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"
              aria-hidden="true"
            >
              <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"
              />
            </svg>
          </button>
        </div>

        <div class="testimonial-lines__main">
          <div class="testimonial-lines__main-details" data-chrome>
            <div class="testimonial-lines__meta">
              <p class="testimonial-lines__p is--faded">
                <span
                  data-current
                  data-odometer-element
                  class="testimonial-lines__count"
                  aria-live="polite"
                  >1</span
                >
                <span class="testimonial-lines__count-sep">/</span>
                <span data-total>5</span>
              </p>
              <div
                class="testimonial-lines__progress"
                data-autoplay-progress
                aria-hidden="true"
              >
                <span class="testimonial-lines__progress-bar"></span>
              </div>
            </div>
            <p class="testimonial-lines__p testimonial-lines__label">
              What our clients say
            </p>
          </div>

          <div class="testimonial-lines__collection">
            <div
              role="list"
              data-testimonial-list
              class="testimonial-lines__list"
            >
              <!-- 1 -->
              <div
                aria-hidden="false"
                data-testimonial-item
                role="listitem"
                class="testimonial-lines__item is--active"
              >
                <h3 data-testimonial-text class="testimonial-lines__h">
                  “Switching platforms felt risky until their onboarding team
                  mapped every workflow with us. Three weeks later our response
                  times dropped by half and the whole floor stopped dreading
                  Mondays.”
                </h3>
                <div class="testimonial-lines__item-details">
                  <div
                    data-testimonial-img
                    class="testimonial-lines__item-visual"
                  >
                    <img
                      src="https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&w=240&h=240&q=90"
                      alt="Elena Vargas"
                      class="testimonial-lines__item-img"
                      draggable="false"
                    />
                  </div>
                  <div class="testimonial-lines__person">
                    <p data-testimonial-split class="testimonial-lines__name">
                      Elena Vargas
                    </p>
                    <p
                      data-testimonial-split
                      class="testimonial-lines__role is--faded"
                    >
                      Head of Ops · Lattice Studio
                    </p>
                  </div>
                </div>
              </div>

              <!-- 2 -->
              <div
                aria-hidden="true"
                data-testimonial-item
                role="listitem"
                class="testimonial-lines__item"
              >
                <h3 data-testimonial-text class="testimonial-lines__h">
                  “We ship campaigns across six time zones. Their dashboard
                  keeps every stakeholder aligned without a single status-call
                  scavenger hunt. Honestly the best quiet win we've had all
                  year.”
                </h3>
                <div class="testimonial-lines__item-details">
                  <div
                    data-testimonial-img
                    class="testimonial-lines__item-visual"
                  >
                    <img
                      src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=240&h=240&q=90"
                      alt="Jonah Park"
                      class="testimonial-lines__item-img"
                      draggable="false"
                    />
                  </div>
                  <div class="testimonial-lines__person">
                    <p data-testimonial-split class="testimonial-lines__name">
                      Jonah Park
                    </p>
                    <p
                      data-testimonial-split
                      class="testimonial-lines__role is--faded"
                    >
                      Creative Director · Northframe
                    </p>
                  </div>
                </div>
              </div>

              <!-- 3 -->
              <div
                aria-hidden="true"
                data-testimonial-item
                role="listitem"
                class="testimonial-lines__item"
              >
                <h3 data-testimonial-text class="testimonial-lines__h">
                  “I measured success in fewer fire drills. Clear ownership,
                  honest timelines, and a team that treats edge cases like
                  first-class work. Our clients noticed before we even told
                  them.”
                </h3>
                <div class="testimonial-lines__item-details">
                  <div
                    data-testimonial-img
                    class="testimonial-lines__item-visual"
                  >
                    <img
                      src="https://images.unsplash.com/photo-1580489944761-15a19d654956?auto=format&fit=crop&w=240&h=240&q=90"
                      alt="Priya Mehta"
                      class="testimonial-lines__item-img"
                      draggable="false"
                    />
                  </div>
                  <div class="testimonial-lines__person">
                    <p data-testimonial-split class="testimonial-lines__name">
                      Priya Mehta
                    </p>
                    <p
                      data-testimonial-split
                      class="testimonial-lines__role is--faded"
                    >
                      Founder · Softline Collective
                    </p>
                  </div>
                </div>
              </div>

              <!-- 4 -->
              <div
                aria-hidden="true"
                data-testimonial-item
                role="listitem"
                class="testimonial-lines__item"
              >
                <h3 data-testimonial-text class="testimonial-lines__h">
                  “Budget season used to mean spreadsheets and guesswork. Now
                  forecasts update live, approvals move overnight, and finance
                  finally trusts the pipeline numbers we send upstairs.”
                </h3>
                <div class="testimonial-lines__item-details">
                  <div
                    data-testimonial-img
                    class="testimonial-lines__item-visual"
                  >
                    <img
                      src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=240&h=240&q=90"
                      alt="Marcus Webb"
                      class="testimonial-lines__item-img"
                      draggable="false"
                    />
                  </div>
                  <div class="testimonial-lines__person">
                    <p data-testimonial-split class="testimonial-lines__name">
                      Marcus Webb
                    </p>
                    <p
                      data-testimonial-split
                      class="testimonial-lines__role is--faded"
                    >
                      VP Growth · Harbor &amp; Co.
                    </p>
                  </div>
                </div>
              </div>

              <!-- 5 -->
              <div
                aria-hidden="true"
                data-testimonial-item
                role="listitem"
                class="testimonial-lines__item"
              >
                <h3 data-testimonial-text class="testimonial-lines__h">
                  “From pitch to production they stayed sharp. Micro-interactions
                  landed, accessibility held, and the handoff docs were so clean
                  our engineers shipped without a single rework loop.”
                </h3>
                <div class="testimonial-lines__item-details">
                  <div
                    data-testimonial-img
                    class="testimonial-lines__item-visual"
                  >
                    <img
                      src="https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&w=240&h=240&q=90"
                      alt="Aisha Rahman"
                      class="testimonial-lines__item-img"
                      draggable="false"
                    />
                  </div>
                  <div class="testimonial-lines__person">
                    <p data-testimonial-split class="testimonial-lines__name">
                      Aisha Rahman
                    </p>
                    <p
                      data-testimonial-split
                      class="testimonial-lines__role is--faded"
                    >
                      Product Lead · Kiln Systems
                    </p>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <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>
    <script src="script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
/* ─────────────────────────────────────────────
   Line Reveal Testimonials — Premium
   ───────────────────────────────────────────── */

:root {
  --bg: #e6ddd0;
  --bg-deep: #d9cfc0;
  --ink: #14120f;
  --ink-soft: rgba(20, 18, 15, 0.58);
  --ink-faint: rgba(20, 18, 15, 0.38);
  --line: rgba(20, 18, 15, 0.12);
  --line-strong: rgba(20, 18, 15, 0.22);
  --glass: rgba(255, 252, 247, 0.42);
  --glass-hover: rgba(255, 252, 247, 0.72);
  --accent: #2c2420;
  --radius-btn: 0.7rem;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --font-sans: "Inter", system-ui, -apple-system, sans-serif;
  --font-serif: "Instrument Serif", "Times New Roman", Georgia, serif;
}

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html,
body {
  height: 100%;
}

body {
  font-family: var(--font-sans);
  background: var(--bg);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

button {
  font: inherit;
  color: inherit;
  cursor: pointer;
}

img {
  max-width: 100%;
  display: block;
}

/* ── Stage ─────────────────────────────────── */

.page {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100%;
  min-height: 100dvh;
  padding: clamp(1.75rem, 5vw, 4.5rem);
  overflow: hidden;
  background:
    radial-gradient(
      120% 90% at 50% 0%,
      #f3ebe0 0%,
      var(--bg) 48%,
      var(--bg-deep) 100%
    );
}

.page__glow {
  position: absolute;
  inset: auto;
  width: min(70vw, 48rem);
  height: min(70vw, 48rem);
  left: 50%;
  top: 42%;
  translate: -50% -50%;
  border-radius: 50%;
  background: radial-gradient(
    circle,
    rgba(255, 248, 238, 0.7) 0%,
    rgba(255, 248, 238, 0) 68%
  );
  pointer-events: none;
  z-index: 0;
}

.page__grain {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 1;
  opacity: 0.28;
  mix-blend-mode: multiply;
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.55'/%3E%3C/svg%3E");
  background-size: 180px 180px;
}

/* ── Layout ────────────────────────────────── */

.testimonial-lines {
  position: relative;
  z-index: 2;
  display: flex;
  flex-flow: wrap;
  justify-content: flex-start;
  align-items: flex-start;
  gap: clamp(1.5rem, 3vw, 2.5rem);
  width: min(100%, 72rem);
}

.testimonial-lines__controls {
  display: flex;
  flex-flow: row;
  justify-content: flex-start;
  align-items: flex-start;
  gap: 0.55em;
  width: 28%;
  min-width: 6.5rem;
}

/* ── Premium nav buttons ───────────────────── */

.testimonial-lines__button {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 2.85em;
  height: 2.85em;
  padding: 0;
  color: var(--ink);
  background: var(--glass);
  border: 1px solid var(--line);
  border-radius: var(--radius-btn);
  box-shadow:
    0 1px 0 rgba(255, 255, 255, 0.55) inset,
    0 8px 24px -12px rgba(40, 28, 16, 0.18);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  transition:
    background 0.35s var(--ease-out),
    border-color 0.35s var(--ease-out),
    box-shadow 0.35s var(--ease-out),
    transform 0.3s var(--ease-out);
}

.testimonial-lines__button:hover {
  background: var(--glass-hover);
  border-color: var(--line-strong);
  box-shadow:
    0 1px 0 rgba(255, 255, 255, 0.7) inset,
    0 12px 28px -10px rgba(40, 28, 16, 0.22);
  transform: translateY(-1px);
}

.testimonial-lines__button:active {
  transform: translateY(0) scale(0.96);
  box-shadow:
    0 1px 0 rgba(255, 255, 255, 0.4) inset,
    0 4px 12px -8px rgba(40, 28, 16, 0.2);
}

.testimonial-lines__button:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

.testimonial-lines__arrow {
  width: 0.78em;
  opacity: 0.88;
}

/* ── Main column ───────────────────────────── */

.testimonial-lines__main {
  display: flex;
  flex-flow: column;
  flex: 1;
  justify-content: flex-start;
  align-items: flex-start;
  gap: clamp(2.75rem, 6vw, 4.75rem);
  min-width: 0;
}

.testimonial-lines__main-details {
  display: flex;
  flex-flow: row;
  flex-wrap: wrap;
  justify-content: flex-start;
  align-items: center;
  gap: 1.25em 1.75em;
  width: 100%;
}

.testimonial-lines__meta {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
  min-width: 3.5rem;
}

/* ── Counter + odometer ────────────────────── */

.testimonial-lines__count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  vertical-align: baseline;
  width: 1.15ch;
  height: 1.2em;
  font-variant-numeric: tabular-nums;
  font-weight: 500;
  overflow: clip;
  position: relative;
}

.testimonial-lines__count-sep {
  margin: 0 0.2em;
  opacity: 0.55;
}

[data-odometer-part="mask"] {
  display: inline-block;
  overflow: clip;
  height: 1.2em;
  line-height: 1.2;
  width: 1ch;
  text-align: center;
}

[data-odometer-part="roller"] {
  display: block;
  white-space: pre;
  text-align: center;
  line-height: 1.2;
  will-change: transform;
}

/* Autoplay progress */
.testimonial-lines__progress {
  position: relative;
  width: 2.75rem;
  height: 1.5px;
  border-radius: 99px;
  background: var(--line);
  overflow: hidden;
}

.testimonial-lines__progress-bar {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  background: var(--ink);
  transform-origin: left center;
  transform: scaleX(0);
}

.testimonial-lines__p {
  margin: 0;
  font-size: 0.9375rem;
  font-weight: 500;
  letter-spacing: 0.01em;
  line-height: 1.3;
}

.testimonial-lines__p.is--faded,
.testimonial-lines__role.is--faded {
  color: var(--ink-soft);
  font-weight: 400;
}

.testimonial-lines__label {
  color: var(--ink-soft);
  font-weight: 400;
  letter-spacing: 0.02em;
}

.testimonial-lines__label::after {
  content: "";
}

/* ── Quote stage ───────────────────────────── */

.testimonial-lines__collection {
  width: 100%;
}

.testimonial-lines__list {
  display: grid;
  position: relative;
  width: 100%;
}

.testimonial-lines__item {
  display: flex;
  flex-flow: column;
  justify-content: flex-start;
  align-items: flex-start;
  gap: clamp(2.25rem, 4.5vw, 3.75rem);
  grid-area: 1 / 1;
  width: 100%;
  position: relative;
  opacity: 0;
  visibility: hidden;
}

.testimonial-lines__item.is--active {
  opacity: 1;
  visibility: visible;
}

.testimonial-lines__h {
  width: min(100%, 38ch);
  margin: 0;
  font-family: var(--font-serif);
  font-size: clamp(1.85rem, 3.6vw, 3.15rem);
  font-weight: 400;
  font-style: normal;
  line-height: 1.12;
  letter-spacing: -0.025em;
  color: var(--ink);
  text-wrap: pretty;
}

/* SplitText mask:lines — avoid descender clipping */
.text-line,
.text-line-mask {
  padding-bottom: 0.18em;
  margin-bottom: -0.18em;
}

/* ── Author ────────────────────────────────── */

.testimonial-lines__item-details {
  display: flex;
  flex-flow: row;
  justify-content: flex-start;
  align-items: center;
  gap: 1.1em;
}

.testimonial-lines__item-visual {
  position: relative;
  aspect-ratio: 1;
  width: 3.65em;
  border-radius: 100em;
  overflow: hidden;
  flex-shrink: 0;
  background: rgba(20, 18, 15, 0.06);
  box-shadow:
    0 0 0 1.5px rgba(255, 255, 255, 0.55),
    0 0 0 2.5px rgba(20, 18, 15, 0.08),
    0 10px 28px -8px rgba(40, 28, 16, 0.28);
}

.testimonial-lines__item-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center top;
}

.testimonial-lines__person {
  display: flex;
  flex-direction: column;
  gap: 0.2em;
  min-width: 0;
}

.testimonial-lines__name {
  margin: 0;
  font-size: 0.9375rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  line-height: 1.25;
  color: var(--ink);
}

.testimonial-lines__role {
  margin: 0;
  font-size: 0.8125rem;
  font-weight: 400;
  letter-spacing: 0.005em;
  line-height: 1.3;
}

/* ── Mobile ────────────────────────────────── */

@media screen and (max-width: 767px) {
  .page {
    padding: 1.5rem 1.25rem 2rem;
    align-items: flex-start;
    padding-top: clamp(2.5rem, 12vh, 4rem);
  }

  .testimonial-lines {
    gap: 2.25rem;
  }

  .testimonial-lines__controls {
    order: 9999;
    width: 100%;
  }

  .testimonial-lines__main {
    gap: 2.5rem;
  }

  .testimonial-lines__h {
    font-size: clamp(1.55rem, 6.5vw, 2rem);
    width: 100%;
  }

  .testimonial-lines__item {
    gap: 2rem;
  }

  .testimonial-lines__item-visual {
    width: 3.15em;
  }

  .testimonial-lines__p,
  .testimonial-lines__name {
    font-size: 0.875rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .testimonial-lines__button {
    transition: none;
  }

  .testimonial-lines__button:hover {
    transform: none;
  }
}
````

## Source — script.js
````javascript
/**
 * Line Reveal Testimonials
 * ─────────────────────────────────────────────────────────────
 *  - SplitText type:"lines" + mask:"lines"
 *  - Outgoing lines: yPercent 0 → -110 (stagger amount 0.25)
 *  - Incoming lines: yPercent 110 → 0 (stagger amount 0.4)
 *  - Avatar: clip-path circle(0%) ↔ circle(50%)
 *  - ease: power4.inOut, overlapping timelines
 *  - Autoplay + keyboard + ScrollTrigger in-view
 *  - Number odometer on the slide counter
 */

gsap.registerPlugin(ScrollTrigger, SplitText);

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]");
    const progressBar = wrap.querySelector(
      "[data-autoplay-progress] .testimonial-lines__progress-bar"
    );

    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 progressTween = 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",
      });
    }

    /* ── Number odometer (digit roller) ─── */

    const ODO_STEP = 1.2; // em — must match CSS line-height
    let odoTween = null;
    let odoValue = activeIndex + 1;

    function initOdometer(value) {
      if (!elCurrent) return;

      // Two 0–9 cycles for room to roll either direction
      const strip = [];
      for (let c = 0; c < 2; c++) {
        for (let d = 0; d <= 9; d++) strip.push(d);
      }

      elCurrent.innerHTML = "";
      elCurrent.setAttribute("aria-label", String(value));

      const mask = document.createElement("span");
      mask.setAttribute("data-odometer-part", "mask");

      const roller = document.createElement("span");
      roller.setAttribute("data-odometer-part", "roller");
      roller.textContent = strip.join("\n");

      mask.appendChild(roller);
      elCurrent.appendChild(mask);

      gsap.set(roller, {
        y: -(value % 10) * ODO_STEP + "em",
        force3D: true,
      });

      elCurrent._odoRoller = roller;
      odoValue = value;
    }

    /**
     * Roll the counter digit like a mechanical odometer.
     * @param {number} nextValue - display number (1-based)
     * @param {number} direction - 1 = next (roll up), -1 = prev (roll down)
     */
    function updateCounter(nextValue, direction = 1) {
      if (!elCurrent) return;

      const to = nextValue;
      const from = odoValue;

      if (to === from && elCurrent._odoRoller) return;

      if (reduceMotion || !elCurrent._odoRoller) {
        initOdometer(to);
        return;
      }

      const roller = elCurrent._odoRoller;
      let startPos;
      let endPos;

      if (direction >= 0) {
        // Roll upward (next): higher y-offset
        startPos = from % 10;
        endPos = to >= from ? to % 10 : 10 + (to % 10); // wrap e.g. 5→1
      } else {
        // Roll downward (prev): start on 2nd cycle, land lower
        startPos = 10 + (from % 10);
        endPos = to <= from ? 10 + (to % 10) : to % 10; // wrap e.g. 1→5
      }

      if (odoTween) odoTween.kill();

      odoTween = gsap.fromTo(
        roller,
        { y: -startPos * ODO_STEP + "em" },
        {
          y: -endPos * ODO_STEP + "em",
          duration: 0.55,
          ease: "power3.inOut",
          force3D: true,
          onComplete: () => {
            // Normalize to first cycle so strip never drifts
            gsap.set(roller, { y: -(to % 10) * ODO_STEP + "em" });
            odoTween = null;
          },
        }
      );

      odoValue = to;
      elCurrent.setAttribute("aria-label", String(to));
    }

    function killProgress() {
      if (progressTween) {
        progressTween.kill();
        progressTween = null;
      }
      if (progressBar) gsap.set(progressBar, { scaleX: 0 });
    }

    function startProgress() {
      if (!progressBar || !autoplayEnabled || reduceMotion) return;
      if (progressTween) progressTween.kill();
      gsap.set(progressBar, { scaleX: 0, transformOrigin: "left center" });
      progressTween = gsap.to(progressBar, {
        scaleX: 1,
        duration: autoplayDuration / 1000,
        ease: "none",
      });
    }

    function startAutoplay() {
      if (!autoplayEnabled) return;
      if (autoplayCall) autoplayCall.kill();
      startProgress();

      autoplayCall = gsap.delayedCall(autoplayDuration / 1000, () => {
        if (!isInView || isAnimating) {
          startAutoplay();
          return;
        }
        goTo((activeIndex + 1) % slides.length, 1);
        startAutoplay();
      });
    }

    function pauseAutoplay() {
      if (autoplayCall) autoplayCall.pause();
      if (progressTween) progressTween.pause();
    }

    function resumeAutoplay() {
      if (!autoplayEnabled) return;
      if (!autoplayCall) startAutoplay();
      else {
        autoplayCall.resume();
        if (progressTween) progressTween.resume();
      }
    }

    function resetAutoplay() {
      if (!autoplayEnabled) return;
      startAutoplay();
    }

    // Set initial state
    slides.forEach((_, i) => setSlideState(i, i === activeIndex));
    initOdometer(activeIndex + 1);

    // Handle reduced motion preference
    gsap.matchMedia().add(
      { reduce: "(prefers-reduced-motion: reduce)" },
      (context) => {
        reduceMotion = context.conditions.reduce;
      }
    );

    // Create SplitText instances — mask:lines for the line reveal
    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, direction = 1) {
      if (isAnimating || nextIndex === activeIndex) return;
      isAnimating = true;

      const outgoingSlide = slides[activeIndex];
      const incomingSlide = slides[nextIndex];

      // Odometer ticks as soon as the transition starts
      updateCounter(nextIndex + 1, direction);

      const tl = gsap.timeline({
        onComplete: () => {
          setSlideState(activeIndex, false);
          setSlideState(nextIndex, true);
          activeIndex = nextIndex;
          isAnimating = false;
        },
      });

      // Reduced motion: simple crossfade
      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();

      // Bring incoming on top (still hidden via yPercent)
      gsap.set(incomingSlide.item, { autoAlpha: 1, pointerEvents: "auto" });
      gsap.set(incomingLines, { yPercent: 110 });

      if (outgoingSlide.image) {
        gsap.set(outgoingSlide.image, { clipPath: imageClipVisible });
      }

      // 1) Outgoing lines rise out of their masks
      tl.to(
        outgoingLines,
        {
          yPercent: -110,
          duration: 0.6,
          ease: "power4.inOut",
          stagger: { amount: 0.25 },
        },
        0
      );

      // 1b) Outgoing avatar shrinks via circle clip
      if (outgoingSlide.image) {
        tl.to(
          outgoingSlide.image,
          {
            clipPath: imageClipHidden,
            duration: 0.6,
            ease: "power4.inOut",
          },
          0
        );
      }

      // 2) Incoming lines rise into view (overlap previous by 0.3s)
      tl.to(
        incomingLines,
        {
          yPercent: 0,
          duration: 0.7,
          ease: "power4.inOut",
          stagger: { amount: 0.4 },
        },
        ">-=0.3"
      );

      // 2b) Incoming avatar expands from center
      if (incomingSlide.image) {
        tl.fromTo(
          incomingSlide.image,
          { clipPath: imageClipHidden },
          {
            clipPath: imageClipVisible,
            duration: 0.75,
            ease: "power4.inOut",
          },
          "<"
        );
      }

      // Hide outgoing after transition
      tl.set(outgoingSlide.item, { autoAlpha: 0 }, ">");
    }

    // Autoplay (data-autoplay="true")
    startAutoplay();

    if (btnNext) {
      btnNext.addEventListener("click", () => {
        resetAutoplay();
        goTo((activeIndex + 1) % slides.length, 1);
      });
    }

    if (btnPrev) {
      btnPrev.addEventListener("click", () => {
        resetAutoplay();
        goTo((activeIndex - 1 + slides.length) % slides.length, -1);
      });
    }

    function onKeyDown(e) {
      if (!isInView) return;

      // Don't hijack arrow keys while user is typing
      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, 1);
      }

      if (e.key === "ArrowLeft") {
        e.preventDefault();
        resetAutoplay();
        goTo((activeIndex - 1 + slides.length) % slides.length, -1);
      }
    }

    window.addEventListener("keydown", onKeyDown);

    // Keyboard + autoplay only while the block is in view
    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();
      },
    });

    // Soft chrome entrance
    if (!reduceMotion) {
      const chrome = wrap.querySelectorAll("[data-chrome]");
      gsap.fromTo(
        chrome,
        { autoAlpha: 0, y: -10 },
        {
          autoAlpha: 1,
          y: 0,
          duration: 0.9,
          ease: "power3.out",
          stagger: 0.08,
          delay: 0.08,
        }
      );
    }
  });
}

// Initialize after fonts so SplitText line breaks match final layout
document.addEventListener("DOMContentLoaded", () => {
  const start = () => initLineRevealTestimonials();

  if (document.fonts?.ready) {
    document.fonts.ready.then(start).catch(start);
  } else {
    start();
  }
});
````
