<div class="button-cont">
  <button class="prev">prev</button>
  <button class="toggle">toggle overflow</button>
  <button class="next">next</button>
</div>

<div class="wrapper">
  <div class="box">
    <div class="box__inner">
      <p>1</p>
    </div>
  </div>
  <div class="box">
    <div class="box__inner">
      <p>2</p>
    </div>
  </div>
  <div class="box">
    <div class="box__inner">
      <p>3</p>
    </div>
  </div>
  <div class="box">
    <div class="box__inner">
      <p>4</p>
    </div>
  </div>
  <div class="box" style="width:350px">
    <div class="box__inner">
      <p>5</p>
    </div>
  </div>
  <div class="box">
    <div class="box__inner">
      <p>6</p>
    </div>
  </div>
  <div class="box">
    <div class="box__inner">
      <p>7</p>
    </div>
  </div>
  <div class="box">
    <div class="box__inner">
      <p>8</p>
    </div>
  </div>
  <div class="box">
    <div class="box__inner">
      <p>9</p>
    </div>
  </div>
  <div class="box">
    <div class="box__inner">
      <p>10</p>
    </div>
  </div>
  <div class="box">
    <div class="box__inner">
      <p>11</p>
    </div>
  </div>
</div>
* {
  box-sizing: border-box;
}

body {
  font-family: system-ui;
  background: var(--color-just-black);
  color: white;
  text-align: center;
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  height: 100vh;
}

.button-cont {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 2rem;
  gap: 1rem;
}

.wrapper {
  height: 300px;
  max-height: 50vh;
  width: 70%;
  border-left: dashed 2px var(--color-surface50);
  border-right: dashed 2px var(--color-surface50);
  position: relative;
  display: flex;
  align-items: center;
  overflow: hidden;
}

.carousel {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

.box {
  padding: 0.5rem;
  flex-shrink: 0;
  height: 80%;
  width: 20%;
  min-width: 150px;
}

.box__inner {
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  font-size: 21px;
  cursor: pointer;
  width: 100%;
  height: 100%;
}

.show-overflow {
  overflow: visible;
}

.box .box__inner {
  background: linear-gradient(var(--color-just-black), var(--color-just-black))
      padding-box,
    var(--gradient) border-box;
  border: 3px solid transparent;
  border-radius: 10px;
}

.box {
  --gradient: var(--gradient-macha);
}

.box:nth-child(3n + 2) {
  --gradient: var(--gradient-summer-fair);
}

.box:nth-child(3n + 1) {
  --gradient: var(--gradient-orange-crush);
}

.box p {
  -webkit-text-fill-color: transparent;
  background: var(--gradient);
  -webkit-background-clip: text;
  background-clip: text;
  font-size: 3rem;
}
const wrapper = document.querySelector(".wrapper");
const boxes = gsap.utils.toArray(".box");


const loop = horizontalLoop(boxes, {paused: true});

boxes.forEach((box, i) => box.addEventListener("click", () => loop.toIndex(i, {duration: 0.8, ease: "power1.inOut"})));

document.querySelector(".toggle").addEventListener("click", () => wrapper.classList.toggle("show-overflow"));
document.querySelector(".next").addEventListener("click", () => loop.next({duration: 0.4, ease: "power1.inOut"}));
document.querySelector(".prev").addEventListener("click", () => loop.previous({duration: 0.4, ease: "power1.inOut"}));


/*
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

  1. https://codepen.io/GreenSock/pen/qEWKyrL.css

External JavaScript

  1. https://unpkg.co/gsap@3/dist/gsap.min.js