SCSS

by Jordan Sayner

HTML

<div class="testimonial-slider" data-testimonial-slider>
  <!-- Left: image stage -->
  <div class="testimonial-slider__media" aria-hidden="true">
    <div class="testimonial-slider__media-stage">
      <img class="ts-img ts-img--current" alt="">
      <img class="ts-img ts-img--next" alt="">
    </div>
  </div>

  <!-- Right: quotes + controls -->
  <div class="testimonial-slider__content">
    <div class="testimonial-slider__list" role="group" aria-live="polite">

      <!-- Slide 1 -->
      <article class="testimonial-slider__item is-active" data-image="https://picsum.photos/800/600?random=1">
        <blockquote class="testimonial-slider__quote">
          <p data-anim>
            She often describes her sessions as her ‘drama family’ and knows that, apart from her actual home, this is also a place that feels like home to her.
          </p>
        </blockquote>
        <p class="testimonial-slider__meta" data-anim>Youth Theatre Parent</p>
      </article>

      <!-- Slide 2 -->
      <article class="testimonial-slider__item" data-image="https://picsum.photos/800/600?random=2">
        <blockquote class="testimonial-slider__quote">
          <p data-anim>
            The confidence and friendships our son has found here are incredible. He counts down the days to each session.
          </p>
        </blockquote>
        <p class="testimonial-slider__meta" data-anim>Parent of Participant</p>
      </article>

      <!-- Slide 3 -->
      <article class="testimonial-slider__item" data-image="https://picsum.photos/800/600?random=3">
        <blockquote class="testimonial-slider__quote">
          <p data-anim>
            Workshops feel welcoming, challenging and really supportive. It’s become a second home.
          </p>
        </blockquote>
        <p class="testimonial-slider__meta" data-anim>Youth Theatre Member</p>
      </article>

    </div>

    <div class="testimonial-slider__controls">
      <div...

SCSS

.testimonial-slider {
  --radius: 24px;
  --pad: clamp(16px, 3vw, 32px);
  --gap: clamp(16px, 4vw, 40px);
  --text-max: 60ch;
  --fade: 420ms;
  --e: cubic-bezier(.22,.61,.36,1);
  --stagger-step: 70ms;

  display: grid;
  grid-template-columns: 1.1fr 1fr;
  gap: var(--gap);
  align-items: stretch;
  background: #fff;
  border-radius: var(--radius);
  overflow: hidden;
  box-shadow:
    0 1px 2px rgba(0,0,0,.05),
    0 8px 40px rgba(0,0,0,.12);

  @media (max-width: 900px) {
    grid-template-columns: 1fr;
  }

  &__media {
    position: relative;
    min-height: 420px;
    @media (max-width: 900px) { min-height: 260px; }
    background: #111;
  }

  &__media-stage {
    position: absolute; inset: 0;
    > .ts-img {
      position: absolute; inset: 0;
      width: 100%; height: 100%;
      object-fit: cover;
      opacity: 0;
  transform: scale(1.02);
  transition: opacity var(--fade) var(--e);
      &.ts-img--current { opacity: 1; transform: none; }
      &.ts-img--next.ts-img--in { opacity: 1; transform: none; }
    }
  }

  &__content {
    position: relative;
    display: grid;
    grid-template-rows: 1fr auto;
    padding: var(--pad);
    background: radial-gradient(120% 140% at 100% 100%, #ffd7e0 0%, #fff2b8 40%, #fff 75%);
  }

  &__list {
    position: relative;
    /* We animate this height to accommodate variable copy length */
    transition: height var(--fade) var(--e);
  }

  &__item {
    position: absolute; inset: 0;
    opacity: 0; transform: translateY(6px);
    transition: opacity var(--fade) var(--e), transform var(--fade) var(--e);
    max-width: var(--text-max);

    blockquote {
      margin: 0 0 16px 0;
    }

    &.is-active {
      position: relative;
      opacity: 1; transform: none;
    }

    /* children stagger base state */
    [data-anim] {
      opacity: 0;
      transform: translateY(12px);
      transition: opacity 420ms var(--e), transform 420ms...

JavaScript

(function () {
  const root = document.querySelector('[data-testimonial-slider]');
  if (!root) return;

  const list = root.querySelector('.testimonial-slider__list');
  const slides = Array.from(root.querySelectorAll('.testimonial-slider__item'));

  // Build pagination
  const pagination = root.querySelector('.testimonial-slider__pagination');
  const arrows = {
    prev: root.querySelector('.ts-arrow--prev'),
    next: root.querySelector('.ts-arrow--next')
  };

  slides.forEach((_, i) => {
    const dot = document.createElement('button');
    dot.type = 'button';
    dot.setAttribute('role', 'tab');
    dot.setAttribute('aria-label', `Go to testimonial ${i+1}`);
    dot.addEventListener('click', () => goTo(i));
    pagination.appendChild(dot);
  });

  const dots = Array.from(pagination.children);

  // Media stage (crossfade between two <img>s)
  const imgCurrent = root.querySelector('.ts-img--current');
  const imgNext = root.querySelector('.ts-img--next');

  let index = Math.max(0, slides.findIndex(s => s.classList.contains('is-active')));
  if (index === -1) index = 0;

  function setListHeight(toSlide) {
    const h = toSlide.offsetHeight;
    list.style.height = h + 'px';
  }

  function prepTextAnimation(slide) {
    // reset anim elements to base state before anim in
    slide.classList.remove('is-in');
    slide.querySelectorAll('[data-anim]').forEach(el => {
      el.style.transitionDelay = '0ms';
    });
    // force reflow so the following class change triggers transitions
    void slide.offsetWidth;
    // stagger in
    const animEls = Array.from(slide.querySelectorAll('[data-anim]'));
    animEls.forEach((el, i) => {
      el.style.transitionDelay = (i * getStaggerStep()) + 'ms';
    });
    slide.classList.add('is-in');
  }

  function getStaggerStep() {
    // Keep in sync with --stagger-step
    return 70;
  }

  function updateDots() {
    dots.forEach((d, i) =>...