<div class="text">
  <h1>Our Best Clients šŸ’«</h1>
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus corporis beatae dignissimos eum optio voluptas totam hic! Minus quae similique, atque at possimus voluptate maxime eum nostrum deserunt excepturi eaque!</p>
</div>
<div class="marquee" data-reversed="false">
  <div class="marquee-item">
    <img width="176" height="40" src="https://assets.codepen.io/162656/marquee-logo1.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="186" height="41" src="https://assets.codepen.io/162656/marquee-logo2.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="138" height="36" src="https://assets.codepen.io/162656/marquee-logo3.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="138" height="30" src="https://assets.codepen.io/162656/marquee-logo4.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="169" height="40" src="https://assets.codepen.io/162656/marquee-logo5.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="109" height="43" src="https://assets.codepen.io/162656/marquee-logo6.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="73" height="49" src="https://assets.codepen.io/162656/marquee-logo7.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="127" height="39" src="https://assets.codepen.io/162656/marquee-logo8.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="49" height="48" src="https://assets.codepen.io/162656/marquee-logo9.svg" alt="" />
  </div>
  <div class="marquee-item">
    <img width="161" height="44" src="https://assets.codepen.io/162656/marquee-logo10.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="160" height="46" src="https://assets.codepen.io/162656/marquee-logo11.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="167" height="41" src="https://assets.codepen.io/162656/marquee-logo12.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="125" height="40" src="https://assets.codepen.io/162656/marquee-logo13.svg" alt="" />
  </div>
  <div class="marquee-item">
    <img width="170" height="41" src="https://assets.codepen.io/162656/marquee-logo14.svg" alt="client logo">
  </div>
</div>
<div class="marquee" data-reversed="true">
  <div class="marquee-item">
    <img width="138" height="36" src="https://assets.codepen.io/162656/marquee-logo3.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="49" height="48" src="https://assets.codepen.io/162656/marquee-logo9.svg" alt="" />
  </div>
  <div class="marquee-item">
    <img width="138" height="30" src="https://assets.codepen.io/162656/marquee-logo4.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="138" height="30" src="https://assets.codepen.io/162656/marquee-logo12.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="109" height="43" src="https://assets.codepen.io/162656/marquee-logo6.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="169" height="40" src="https://assets.codepen.io/162656/marquee-logo5.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="160" height="46" src="https://assets.codepen.io/162656/marquee-logo11.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="127" height="39" src="https://assets.codepen.io/162656/marquee-logo8.svg" alt="" />
  </div>
  <div class="marquee-item">
    <img width="73" height="49" src="https://assets.codepen.io/162656/marquee-logo7.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="125" height="40" src="https://assets.codepen.io/162656/marquee-logo13.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="170" height="41" src="https://assets.codepen.io/162656/marquee-logo14.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="161" height="44" src="https://assets.codepen.io/162656/marquee-logo10.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="176" height="40" src="https://assets.codepen.io/162656/marquee-logo1.svg" alt="client logo" />
  </div>
  <div class="marquee-item">
    <img width="186" height="41" src="https://assets.codepen.io/162656/marquee-logo2.svg" alt="client logo">
  </div>
</div>

<footer class="page-footer">
  <span>made by </span>
  <a href="https://georgemartsoukos.com/" target="_blank">
    <img width="24" height="24" src="https://assets.codepen.io/162656/george-martsoukos-small-logo.svg" alt="George Martsoukos logo">
  </a>
</footer>
/* BASIC STYLES
ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ */
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap");

* {
  box-sizing: border-box;
}

img {
  max-width: 100%;
  height: auto;
}

body {
  font-family: "DM Sans", sans-serif;
  margin: 50px 0;
  background: whitesmoke;
}

.text {
  max-width: 1000px;
  padding: 0 15px;
  margin: 0 auto 50px;
  font-size: 1.5rem;
}

/* MARQUEE STYLES
ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ */
.marquee {
  position: relative;
  display: flex;
  gap: 20px;
  overflow: hidden;
}

.marquee + .marquee {
  margin-top: 20px;
}

.marquee-item {
  width: 150px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 15px;
  flex-shrink: 0;
  background: white;
  border-radius: 10px;
}

@media (min-width: 700px) {
  .marquee {
    gap: 40px;
  }

  .marquee + .marquee {
    margin-top: 40px;
  }

  .marquee-item {
    width: 250px;
    padding: 30px;
  }
}

/* FOOTER STYLES
ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ā€“ */
.page-footer {
  position: fixed;
  right: 0;
  bottom: 50px;
  display: flex;
  align-items: center;
  padding: 5px;
  z-index: 1;
  font-size: 14px;
  background: white;
}

.page-footer a {
  display: flex;
  margin-left: 4px;
}
const marquees = document.querySelectorAll(".marquee");
const mm = gsap.matchMedia();

window.addEventListener("load", function () {
  mm.add(
    {
      isMobile: "(max-width: 699px)",
      isDesktop: "(min-width: 700px)"
    },
    (context) => {
      const { isMobile, isDesktop } = context.conditions;

      marquees.forEach((marquee) => {
        const marqueeItems = marquee.querySelectorAll(".marquee-item");
        const reversed =
          marquee.getAttribute("data-reversed") === "true" ? true : false;

        horizontalLoop(marqueeItems, {
          repeat: -1,
          paddingRight: isDesktop ? 40 : 20,
          speed: isDesktop ? 0.5 : 0.25,
          reversed
        });
      });
    }
  );
});

/*
This helper function makes a group of elements animate along the x-axis in a seamless, responsive loop.

Features:
 - Uses xPercent so that even if the widths change (like if the window gets resized), it should still work in most cases.
 - When each item animates to the left or right enough, it will loop back to the other side
 - Optionally pass in a config object with values like "speed" (default: 1, which travels at roughly 100 pixels per second), paused (boolean),  repeat, reversed, and paddingRight.
 - The returned timeline will have the following methods added to it:
   - next() - animates to the next element using a timeline.tweenTo() which it returns. You can pass in a vars object to control duration, easing, etc.
   - previous() - animates to the previous element using a timeline.tweenTo() which it returns. You can pass in a vars object to control duration, easing, etc.
   - toIndex() - pass in a zero-based index value of the element that it should animate to, and optionally pass in a vars object to control duration, easing, etc. Always goes in the shortest direction
   - current() - returns the current index (if an animation is in-progress, it reflects the final index)
   - times - an Array of the times on the timeline where each element hits the "starting" spot. There's also a label added accordingly, so "label1" is when the 2nd element reaches the start.
 */
function horizontalLoop(items, config) {
  items = gsap.utils.toArray(items);
  config = config || {};
  let tl = gsap.timeline({
      repeat: config.repeat,
      paused: config.paused,
      defaults: { ease: "none" },
      onReverseComplete: () => tl.totalTime(tl.rawTime() + tl.duration() * 100)
    }),
    length = items.length,
    startX = items[0].offsetLeft,
    times = [],
    widths = [],
    xPercents = [],
    curIndex = 0,
    pixelsPerSecond = (config.speed || 1) * 100,
    snap = config.snap === false ? (v) => v : gsap.utils.snap(config.snap || 1), // some browsers shift by a pixel to accommodate flex layouts, so for example if width is 20% the first element's width might be 242px, and the next 243px, alternating back and forth. So we snap to 5 percentage points to make things look more natural
    totalWidth,
    curX,
    distanceToStart,
    distanceToLoop,
    item,
    i;
  gsap.set(items, {
    // convert "x" to "xPercent" to make things responsive, and populate the widths/xPercents Arrays to make lookups faster.
    xPercent: (i, el) => {
      let w = (widths[i] = parseFloat(gsap.getProperty(el, "width", "px")));
      xPercents[i] = snap(
        (parseFloat(gsap.getProperty(el, "x", "px")) / w) * 100 +
          gsap.getProperty(el, "xPercent")
      );
      return xPercents[i];
    }
  });
  gsap.set(items, { x: 0 });
  totalWidth =
    items[length - 1].offsetLeft +
    (xPercents[length - 1] / 100) * widths[length - 1] -
    startX +
    items[length - 1].offsetWidth *
      gsap.getProperty(items[length - 1], "scaleX") +
    (parseFloat(config.paddingRight) || 0);
  for (i = 0; i < length; i++) {
    item = items[i];
    curX = (xPercents[i] / 100) * widths[i];
    distanceToStart = item.offsetLeft + curX - startX;
    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;
  }
  function toIndex(index, vars) {
    vars = vars || {};
    Math.abs(index - curIndex) > length / 2 &&
      (index += index > curIndex ? -length : length); // always go in the shortest direction
    let newIndex = gsap.utils.wrap(0, length, index),
      time = times[newIndex];
    if (time > tl.time() !== index > curIndex) {
      // if we're wrapping the timeline's playhead, make the proper adjustments
      vars.modifiers = { time: gsap.utils.wrap(0, tl.duration()) };
      time += tl.duration() * (index > curIndex ? 1 : -1);
    }
    curIndex = newIndex;
    vars.overwrite = true;
    return tl.tweenTo(time, vars);
  }
  tl.next = (vars) => toIndex(curIndex + 1, vars);
  tl.previous = (vars) => toIndex(curIndex - 1, vars);
  tl.current = () => curIndex;
  tl.toIndex = (index, vars) => toIndex(index, vars);
  tl.times = times;
  tl.progress(1, true).progress(0, true); // pre-render for performance
  if (config.reversed) {
    tl.vars.onReverseComplete();
    tl.reverse();
  }
  return tl;
}

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js