# GSAP Split This Infinite Content Slider

> An infinite content slider powered by GSAP SplitText-style motion — continuous, editorial-grade scrolling.

**Definition:** GSAP Split This Infinite Content Slider is a sliders 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: Sliders
- Stack: HTML, CSS, JavaScript, and GSAP
- Added: 2026-08-01
- Canonical page: https://html.bosconvertpro.com/collection/gsap-split-this-infinite-content-slider
- Full-page preview: https://html.bosconvertpro.com/collection/gsap-split-this-infinite-content-slider/full
- Live demo: https://html.bosconvertpro.com/demos/gsap-split-this-infinite-content-slider/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/gsap-split-this-infinite-content-slider/index.md
- License: https://html.bosconvertpro.com/terms

## How to use
1. Open https://html.bosconvertpro.com/collection/gsap-split-this-infinite-content-slider/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/gsap-split-this-infinite-content-slider.
3. Paste the files into your project and adjust timing, easing, and content as needed.

## FAQ
### What is GSAP Split This Infinite Content Slider?

GSAP Split This Infinite Content Slider is a sliders UI animation from collections.html. An infinite content slider powered by GSAP SplitText-style motion — continuous, editorial-grade scrolling.

### What is GSAP Split This Infinite Content Slider built with?

GSAP Split This Infinite Content Slider 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 GSAP Split This Infinite Content Slider in my project?

Open the animation page (https://html.bosconvertpro.com/collection/gsap-split-this-infinite-content-slider), 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/gsap-split-this-infinite-content-slider/index.html.

### Is GSAP Split This Infinite Content Slider free to use?

Yes. The GSAP Split This Infinite Content Slider 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" />
    <meta name="description" content="Abbos — Split infinite content slider" />
    <title>Abbos — Work</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 rel="preconnect" href="https://cdn.jsdelivr.net" />

    <!-- Elza-adjacent: black grotesk display + clean UI -->
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Schibsted+Grotesk:wght@700;800;900&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <!-- Loader -->
    <div class="loader" data-loader aria-hidden="true">
      <div class="loader__inner">
        <span class="loader__brand">ABBOS</span>
        <div class="loader__bar"><span data-loader-bar></span></div>
      </div>
    </div>

    <!-- Brand wordmark -->
    <div class="site-brand" data-brand aria-hidden="true">ABBOS</div>

    <!-- Split infinite project slider -->
    <main class="split-slider" id="work" data-slider aria-label="Featured work">
      <div class="split-slider__seam" aria-hidden="true"></div>

      <div class="split-slider__col is-left" data-col="left">
        <div class="split-slider__track" data-track="left"></div>
      </div>

      <div class="split-slider__col is-right" data-col="right">
        <div class="split-slider__track" data-track="right"></div>
      </div>

      <p class="sr-only" aria-live="polite" data-live></p>
    </main>

    <!-- Slide counter -->
    <div class="slide-count" data-count aria-hidden="true">
      <span data-count-current>01</span>
      <span class="slide-count__sep">/</span>
      <span data-count-total>08</span>
    </div>

    <!-- GSAP core + Observer (professional input) -->
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Observer.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
/* ==========================================================================
   Split Infinite Content Slider
   Faithful recreation of glitchandgrit.com home showcase language
   ========================================================================== */

:root {
  --bg: #000;
  --text: #f5f4f2;
  --text-faded: rgba(245, 244, 242, 0.72);
  --seam: rgba(255, 255, 255, 0.12);
  --overlay: rgba(0, 0, 0, 0.4); /* site uses ~40% visual overlay */
  --font-ui: "Inter", system-ui, -apple-system, "Helvetica Neue", sans-serif;
  /* Schibsted Grotesk ≈ Elza weight/feel for free */
  --font-display: "Schibsted Grotesk", "Arial Black", Impact, sans-serif;
  --pad: clamp(1rem, 2.2vw, 1.75rem);
  --title-size: clamp(2.75rem, 10.5vw, 8rem); /* site display ~6–8rem */
  --title-lh: 0.9;
  --title-ls: -0.035em;
  --ui-size: clamp(0.7rem, 0.95vw, 0.8125rem);
  --ui-ls: 0.04em;
  --ease-expo: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
}

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

html,
body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-ui);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  touch-action: none;
  user-select: none;
}

img,
video {
  display: block;
  max-width: none;
  pointer-events: none;
}

a {
  color: inherit;
  text-decoration: none;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  cursor: pointer;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* --------------------------------------------------------------------------
   Loader
   -------------------------------------------------------------------------- */
.loader {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: grid;
  place-items: center;
  background: #000;
  color: var(--text);
}

.loader.is-done {
  pointer-events: none;
}

.loader__inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.25rem;
  width: min(220px, 50vw);
}

.loader__brand {
  font-family: var(--font-display);
  font-size: clamp(0.85rem, 1.4vw, 1rem);
  font-weight: 800;
  letter-spacing: 0.28em;
  text-transform: uppercase;
}

.loader__bar {
  width: 100%;
  height: 1px;
  background: rgba(255, 255, 255, 0.12);
  overflow: hidden;
}

.loader__bar span {
  display: block;
  width: 100%;
  height: 100%;
  background: var(--text);
  transform: scaleX(0);
  transform-origin: left center;
}

/* --------------------------------------------------------------------------
   Brand wordmark
   -------------------------------------------------------------------------- */
.site-brand {
  position: fixed;
  left: var(--pad);
  bottom: var(--pad);
  z-index: 50;
  font-family: var(--font-display);
  font-size: var(--ui-size);
  font-weight: 800;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: #fff;
  mix-blend-mode: difference;
  opacity: 0;
  pointer-events: none;
}

/* --------------------------------------------------------------------------
   Slide counter
   -------------------------------------------------------------------------- */
.slide-count {
  position: fixed;
  right: var(--pad);
  bottom: var(--pad);
  z-index: 45;
  display: flex;
  align-items: baseline;
  gap: 0.25rem;
  font-size: var(--ui-size);
  font-weight: 500;
  letter-spacing: 0.12em;
  font-variant-numeric: tabular-nums;
  color: #fff;
  mix-blend-mode: difference;
  opacity: 0;
  pointer-events: none;
}

.slide-count__sep {
  opacity: 0.4;
}

/* --------------------------------------------------------------------------
   Split slider shell
   -------------------------------------------------------------------------- */
.split-slider {
  position: relative;
  width: 100vw;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  background: var(--bg);
  cursor: grab;
}

.split-slider.is-dragging {
  cursor: grabbing;
}

.split-slider__seam {
  position: absolute;
  top: 0;
  left: 50%;
  z-index: 30;
  width: 1px;
  height: 100%;
  background: var(--seam);
  transform: translateX(-50%);
  pointer-events: none;
}

.split-slider__col {
  position: absolute;
  top: 0;
  width: 50%;
  height: 100%;
  overflow: hidden;
  z-index: 1;
}

.split-slider__col.is-left {
  left: 0;
}

.split-slider__col.is-right {
  right: 0;
}

.split-slider__track {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  will-change: transform;
}

/* --------------------------------------------------------------------------
   Slides
   Full-viewport width so centered type joins across the seam.
   -------------------------------------------------------------------------- */
.slide {
  position: absolute;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  will-change: transform;
}

.split-slider__col.is-right .slide {
  left: -50vw;
}

/* Media — each half can show a different frame/image (site uses dual video) */
.slide__media {
  position: absolute;
  inset: 0;
}

.slide__media img,
.slide__media video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center center;
  transform: scale(1.06);
  will-change: transform;
}

/* Left half prefers left-weighted crop; right half right-weighted */
.split-slider__col.is-left .slide__media img,
.split-slider__col.is-left .slide__media video {
  object-position: 30% center;
}

.split-slider__col.is-right .slide__media img,
.split-slider__col.is-right .slide__media video {
  object-position: 70% center;
}

.slide__veil {
  position: absolute;
  inset: 0;
  background: var(--overlay);
  pointer-events: none;
}

/* Soft vignette so type stays legible without killing the photo */
.slide__vignette {
  position: absolute;
  inset: 0;
  background: radial-gradient(
    ellipse 70% 60% at 50% 50%,
    transparent 0%,
    rgba(0, 0, 0, 0.18) 100%
  );
  pointer-events: none;
}

/* --------------------------------------------------------------------------
   Content — identical in both halves so glyphs meet on the seam
   -------------------------------------------------------------------------- */
.slide__content {
  position: absolute;
  inset: 0;
  z-index: 5;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: clamp(5rem, 12vh, 7rem) 1.25rem;
  pointer-events: none;
  color: var(--accent, #99f8ff);
}

.slide__cats {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  margin-bottom: clamp(0.65rem, 1.6vh, 1.15rem);
  font-size: clamp(0.65rem, 1vw, 0.8125rem);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  line-height: 1.3;
  opacity: 0.95;
}

.slide__title {
  font-family: var(--font-display);
  font-size: var(--title-size);
  font-weight: 800;
  line-height: var(--title-lh);
  letter-spacing: var(--title-ls);
  text-transform: uppercase;
  max-width: 12ch; /* site constraint */
  margin: 0 auto;
}

.slide__title span {
  display: block;
}

.slide__cta {
  margin-top: clamp(0.85rem, 1.8vh, 1.35rem);
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  font-size: clamp(0.65rem, 1vw, 0.8125rem);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: inherit;
  opacity: 0.95;
  pointer-events: auto;
  transition: opacity 0.35s var(--ease-expo);
}

.slide__cta:hover,
.slide__cta:focus-visible {
  opacity: 0.5;
  outline: none;
}

.split-slider__col.is-right .slide__cta {
  pointer-events: none;
}

.slide__cta svg {
  width: 0.78em;
  height: 0.78em;
  flex-shrink: 0;
  transition: transform 0.35s var(--ease-expo);
}

.slide__cta:hover svg {
  transform: translate(2px, -2px);
}

/* --------------------------------------------------------------------------
   Reduced motion
   -------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  .slide__media img,
  .slide__media video {
    transform: none !important;
  }
}

/* --------------------------------------------------------------------------
   Mobile
   -------------------------------------------------------------------------- */
@media (max-width: 720px) {
  :root {
    --title-size: clamp(2.15rem, 12.5vw, 3.4rem);
    --title-ls: -0.03em;
  }

  .slide__content {
    padding-inline: 0.85rem;
  }

  .slide__cats {
    letter-spacing: 0.1em;
  }

  .slide-count {
    display: none;
  }
}

/* Ultra-wide: keep titles from ballooning past site feel */
@media (min-width: 1600px) {
  :root {
    --title-size: 8rem;
  }
}
````

## Source — script.js
````javascript
/**
 * Split Infinite Content Slider
 * ------------------------------------------------------------------
 * Recreates the glitchandgrit.com home project showcase language:
 *  - Viewport split down the middle (dual media per project)
 *  - On change, LEFT & RIGHT halves travel opposite directions
 *  - Exact accent colors from the live site
 *  - GSAP + Observer for wheel / touch / pointer
 */

(() => {
  "use strict";

  /* --------------------------------------------------------------------
     Projects — Abbos portfolio copy
     Dual media (left ≠ right). All Unsplash URLs verified HTTP 200.
     -------------------------------------------------------------------- */
  const img = (id) =>
    `https://images.unsplash.com/${id}?auto=format&fit=crop&w=1800&q=80`;

  const PROJECTS = [
    {
      id: "nova-studios",
      title: ["NOVA", "STUDIOS"],
      categories: ["Content & Marketing", "Web & Digital", "Brand & Identity"],
      cta: "COMING SOON",
      href: "#",
      accent: "#99f8ff",
      imageLeft: img("photo-1522071820081-009f0129c71c"),
      imageRight: img("photo-1600880292203-757bb62b4baf"),
    },
    {
      id: "frame-and-field",
      title: ["FRAME", "AND FIELD"],
      categories: ["Film & Documentary"],
      cta: "VIEW FULL PROJECT",
      href: "#",
      accent: "#dcc7ff",
      imageLeft: img("photo-1452587925148-ce544e77e70d"),
      imageRight: img("photo-1478720568477-152d9b164e26"),
    },
    {
      id: "atlas-talent",
      title: ["ATLAS", "TALENT"],
      categories: ["Content & Marketing", "Brand & Identity"],
      cta: "VIEW FULL PROJECT",
      href: "#",
      accent: "#6081d6",
      imageLeft: img("photo-1492691527719-9d1e07e534b4"),
      imageRight: img("photo-1534528741775-53994a69daeb"),
    },
    {
      id: "horizon-lines",
      title: ["HORIZON", "LINES"],
      categories: ["Film & Documentary"],
      cta: "VIEW FULL PROJECT",
      href: "#",
      accent: "#fff8a5",
      imageLeft: img("photo-1506905925346-21bda4d32df4"),
      imageRight: img("photo-1469474968028-56623f02e42e"),
    },
    {
      id: "pulse-motion",
      title: ["PULSE", "MOTION"],
      categories: ["Content & Marketing", "Brand & Identity"],
      cta: "VIEW FULL PROJECT",
      href: "#",
      accent: "#ffba6a",
      imageLeft: img("photo-1552674605-db6ffd4facb5"),
      imageRight: img("photo-1476480862126-209bfaa8edc8"),
    },
    {
      id: "signal-system",
      title: ["SIGNAL", "SYSTEM"],
      categories: ["Web & Digital", "Content & Marketing", "Brand & Identity"],
      cta: "VIEW FULL PROJECT",
      href: "#",
      accent: "#9be187",
      imageLeft: img("photo-1558655146-d09347e92766"),
      imageRight: img("photo-1581291518633-83b4ebd1d83e"),
    },
    {
      id: "arena-live",
      title: ["ARENA", "LIVE"],
      categories: ["Content & Marketing"],
      cta: "VIEW FULL PROJECT",
      href: "#",
      accent: "#f98dff",
      imageLeft: img("photo-1574629810360-7efbbe195018"),
      imageRight: img("photo-1519389950473-47ba0277781c"),
    },
    {
      id: "form-house",
      title: ["FORM", "HOUSE"],
      categories: ["Content & Marketing", "Film & Documentary"],
      cta: "VIEW FULL PROJECT",
      href: "#",
      accent: "#f98dff",
      imageLeft: img("photo-1497366216548-37526070297c"),
      imageRight: img("photo-1618221195710-dd6b41faaea6"),
    },
  ];

  /* --------------------------------------------------------------------
     Config — tuned for a premium, deliberate feel
     -------------------------------------------------------------------- */
  const CONFIG = {
    duration: 1.2,
    // Custom ease: strong ease-in-out, soft landing (agency-site feel)
    ease: "power4.inOut",
    autoplay: true,
    autoplayDelay: 5.5,
    wheelTolerance: 12,
    dragThreshold: 0.12, // fraction of viewport
    reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
  };

  /* --------------------------------------------------------------------
     DOM
     -------------------------------------------------------------------- */
  const slider = document.querySelector("[data-slider]");
  const trackLeft = document.querySelector('[data-track="left"]');
  const trackRight = document.querySelector('[data-track="right"]');
  const live = document.querySelector("[data-live]");
  const loader = document.querySelector("[data-loader]");
  const loaderBar = document.querySelector("[data-loader-bar]");
  const brand = document.querySelector("[data-brand]");
  const countEl = document.querySelector("[data-count]");
  const countCurrent = document.querySelector("[data-count-current]");
  const countTotal = document.querySelector("[data-count-total]");

  if (!slider || !trackLeft || !trackRight) return;

  /* --------------------------------------------------------------------
     State
     -------------------------------------------------------------------- */
  let index = 0;
  let animating = false;
  let autoplayCall = null;
  let vh = window.innerHeight;
  let ready = false;

  const leftSlides = [];
  const rightSlides = [];

  /* --------------------------------------------------------------------
     Helpers
     -------------------------------------------------------------------- */
  const pad2 = (n) => String(n).padStart(2, "0");

  function escapeHtml(str) {
    return String(str)
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;");
  }

  function loadImage(src) {
    return new Promise((resolve) => {
      const image = new Image();
      image.decoding = "async";
      image.onload = () => resolve({ src, ok: true });
      image.onerror = () => resolve({ src, ok: false });
      image.src = src;
    });
  }

  /** Solid fallback if a remote image ever fails (gradient + noise feel) */
  function fallbackDataUri(accent) {
    const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
      <defs>
        <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stop-color="#111"/>
          <stop offset="100%" stop-color="${accent || "#333"}" stop-opacity="0.45"/>
        </linearGradient>
      </defs>
      <rect width="1600" height="900" fill="url(#g)"/>
    </svg>`;
    return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  }

  /* --------------------------------------------------------------------
     Markup
     -------------------------------------------------------------------- */
  function createSlide(project, side) {
    const el = document.createElement("article");
    el.className = "slide";
    el.dataset.id = project.id;
    el.dataset.side = side;
    el.style.setProperty("--accent", project.accent);

    const src = side === "left" ? project.imageLeft : project.imageRight;
    const alt = `${project.title.join(" ")} — ${side} visual`;
    const cats = project.categories
      .map((c) => `<span>${escapeHtml(c)}</span>`)
      .join("");
    const lines = project.title
      .map((line) => `<span>${escapeHtml(line)}</span>`)
      .join("");

    const coming = /coming/i.test(project.cta);
    const ctaHtml = coming
      ? escapeHtml(project.cta)
      : `${escapeHtml(project.cta)}<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.2 9.8L9.8 2.2M4.2 2.2h5.6v5.6" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;

    el.innerHTML = `
      <div class="slide__media">
        <img
          src="${src}"
          alt="${escapeHtml(alt)}"
          draggable="false"
          decoding="async"
          loading="eager"
          data-fallback="${escapeHtml(fallbackDataUri(project.accent))}"
        />
        <div class="slide__veil"></div>
        <div class="slide__vignette"></div>
      </div>
      <div class="slide__content">
        <div class="slide__cats">${cats}</div>
        <h2 class="slide__title">${lines}</h2>
        <a class="slide__cta" href="${project.href}" ${side === "right" ? 'tabindex="-1" aria-hidden="true"' : ""}>${ctaHtml}</a>
      </div>
    `;

    const media = el.querySelector("img");
    if (media) {
      media.addEventListener("error", function onImgError() {
        const fb = media.getAttribute("data-fallback");
        if (fb && media.src !== fb) {
          media.src = fb;
        }
        media.removeEventListener("error", onImgError);
      });
    }

    return el;
  }

  function build() {
    trackLeft.replaceChildren();
    trackRight.replaceChildren();
    leftSlides.length = 0;
    rightSlides.length = 0;

    PROJECTS.forEach((p) => {
      const L = createSlide(p, "left");
      const R = createSlide(p, "right");
      trackLeft.appendChild(L);
      trackRight.appendChild(R);
      leftSlides.push(L);
      rightSlides.push(R);
    });

    if (countTotal) countTotal.textContent = pad2(PROJECTS.length);
    positionAll(true);
    setCount();
    announce();
  }

  /* --------------------------------------------------------------------
     Positioning
     -------------------------------------------------------------------- */
  function positionAll(immediate) {
    vh = window.innerHeight;
    leftSlides.forEach((el, i) => {
      const y = (i - index) * vh;
      if (immediate) gsap.set(el, { y, force3D: true });
      else gsap.to(el, { y, duration: 0.01, force3D: true });
    });
    rightSlides.forEach((el, i) => {
      const y = (i - index) * vh;
      if (immediate) gsap.set(el, { y, force3D: true });
      else gsap.to(el, { y, duration: 0.01, force3D: true });
    });
  }

  function setCount() {
    if (countCurrent) countCurrent.textContent = pad2(index + 1);
  }

  function announce() {
    const p = PROJECTS[index];
    if (live) {
      live.textContent = `${p.title.join(" ")} — project ${index + 1} of ${PROJECTS.length}`;
    }
  }

  /* --------------------------------------------------------------------
     Ken Burns — subtle continuous zoom on active media
     -------------------------------------------------------------------- */
  function startKenBurns(i) {
    if (CONFIG.reducedMotion) return;
    const imgs = [
      leftSlides[i]?.querySelector("img"),
      rightSlides[i]?.querySelector("img"),
    ].filter(Boolean);

    imgs.forEach((img, n) => {
      gsap.killTweensOf(img);
      gsap.fromTo(
        img,
        { scale: 1.06 },
        {
          scale: 1.14,
          duration: 9,
          ease: "none",
          delay: n * 0.15,
          overwrite: true,
        }
      );
    });
  }

  /* --------------------------------------------------------------------
     Core transition — opposite-direction split
     dir: 1 = next, -1 = prev
     -------------------------------------------------------------------- */
  function goTo(nextIndex, dir) {
    if (!ready || animating) return;

    const total = PROJECTS.length;
    nextIndex = ((nextIndex % total) + total) % total;
    if (nextIndex === index) return;

    if (dir !== 1 && dir !== -1) {
      const fwd = (nextIndex - index + total) % total;
      const bwd = (index - nextIndex + total) % total;
      dir = fwd <= bwd ? 1 : -1;
    }

    // Reduced motion: hard cut
    if (CONFIG.reducedMotion) {
      index = nextIndex;
      positionAll(true);
      setCount();
      announce();
      startKenBurns(index);
      return;
    }

    animating = true;
    stopAutoplay();

    const from = index;
    const to = nextIndex;
    const duration = CONFIG.duration;
    const ease = CONFIG.ease;

    /*
      dir = 1 (next):
        LEFT  exits UP,   enters from BOTTOM
        RIGHT exits DOWN, enters from TOP
      dir = -1: mirrored
    */
    const leftExit = dir === 1 ? -vh : vh;
    const rightExit = dir === 1 ? vh : -vh;
    const leftEnter = dir === 1 ? vh : -vh;
    const rightEnter = dir === 1 ? -vh : vh;

    // Park idle slides out of view
    leftSlides.forEach((el, i) => {
      if (i !== from && i !== to) gsap.set(el, { y: vh * 3, force3D: true });
    });
    rightSlides.forEach((el, i) => {
      if (i !== from && i !== to) gsap.set(el, { y: vh * 3, force3D: true });
    });

    gsap.set(leftSlides[from], { y: 0 });
    gsap.set(rightSlides[from], { y: 0 });
    gsap.set(leftSlides[to], { y: leftEnter });
    gsap.set(rightSlides[to], { y: rightEnter });

    const contentIn = [
      leftSlides[to].querySelector(".slide__content"),
      rightSlides[to].querySelector(".slide__content"),
    ];
    const contentOut = [
      leftSlides[from].querySelector(".slide__content"),
      rightSlides[from].querySelector(".slide__content"),
    ];

    // Reset content transforms so recycled slides never stick mid-tween
    gsap.set(contentIn, { y: dir * 36, opacity: 0.35 });
    gsap.set(contentOut, { y: 0, opacity: 1 });

    const tl = gsap.timeline({
      defaults: { duration, ease, force3D: true },
      onComplete: () => {
        index = to;
        gsap.set([...contentIn, ...contentOut], { clearProps: "opacity,y" });
        positionAll(true);
        animating = false;
        setCount();
        announce();
        startKenBurns(index);
        startAutoplay();
      },
    });

    // Split peel — signature opposite-direction motion
    tl.to(leftSlides[from], { y: leftExit }, 0);
    tl.to(leftSlides[to], { y: 0 }, 0);
    tl.to(rightSlides[from], { y: rightExit }, 0);
    tl.to(rightSlides[to], { y: 0 }, 0);

    // Content settle — slight lag for depth
    tl.to(
      contentIn,
      { y: 0, opacity: 1, duration: duration * 0.85, ease: "power3.out" },
      duration * 0.12
    );
    tl.to(
      contentOut,
      { opacity: 0.4, duration: duration * 0.45, ease: "power2.in" },
      0
    );

    // Media scale handoff
    const outImgs = [
      leftSlides[from].querySelector("img"),
      rightSlides[from].querySelector("img"),
    ];
    const inImgs = [
      leftSlides[to].querySelector("img"),
      rightSlides[to].querySelector("img"),
    ];

    gsap.killTweensOf([...outImgs, ...inImgs]);
    tl.to(outImgs, { scale: 1.04, duration, ease: "power2.inOut" }, 0);
    tl.fromTo(
      inImgs,
      { scale: 1.16 },
      { scale: 1.06, duration, ease: "power2.out" },
      0
    );

    // Counter tick
    if (countCurrent) {
      tl.to(
        countCurrent,
        {
          y: dir * -8,
          opacity: 0,
          duration: 0.25,
          ease: "power2.in",
          onComplete: () => {
            countCurrent.textContent = pad2(to + 1);
            gsap.set(countCurrent, { y: dir * 8 });
          },
        },
        0
      );
      tl.to(
        countCurrent,
        { y: 0, opacity: 1, duration: 0.35, ease: "power2.out" },
        0.3
      );
    }
  }

  function next() {
    goTo(index + 1, 1);
  }

  function prev() {
    goTo(index - 1, -1);
  }

  /* --------------------------------------------------------------------
     Autoplay
     -------------------------------------------------------------------- */
  function startAutoplay() {
    stopAutoplay();
    if (!CONFIG.autoplay || CONFIG.reducedMotion || !ready) return;
    autoplayCall = gsap.delayedCall(CONFIG.autoplayDelay, next);
  }

  function stopAutoplay() {
    if (autoplayCall) {
      autoplayCall.kill();
      autoplayCall = null;
    }
  }

  /* --------------------------------------------------------------------
     Input — GSAP Observer (wheel, touch, pointer)
     -------------------------------------------------------------------- */
  function bindObserver() {
    // GSAP Observer — same pattern award sites use for section steppers
    if (typeof Observer !== "undefined") {
      Observer.create({
        target: slider,
        type: "wheel,touch,pointer",
        wheelSpeed: -1,
        tolerance: CONFIG.wheelTolerance,
        preventDefault: true,
        onDown: () => {
          if (!animating && ready) prev();
        },
        onUp: () => {
          if (!animating && ready) next();
        },
      });
    } else {
      slider.addEventListener(
        "wheel",
        (e) => {
          e.preventDefault();
          if (animating || !ready) return;
          if (e.deltaY > 20) next();
          else if (e.deltaY < -20) prev();
        },
        { passive: false }
      );
    }

    window.addEventListener("keydown", (e) => {
      if (animating || !ready) return;
      const k = e.key;
      if (k === "ArrowDown" || k === "PageDown" || k === " ") {
        e.preventDefault();
        next();
      } else if (k === "ArrowUp" || k === "PageUp") {
        e.preventDefault();
        prev();
      } else if (k === "Home" && index !== 0) {
        e.preventDefault();
        goTo(0, -1);
      } else if (k === "End" && index !== PROJECTS.length - 1) {
        e.preventDefault();
        goTo(PROJECTS.length - 1, 1);
      }
    });

    slider.addEventListener("pointerdown", () => {
      slider.classList.add("is-dragging");
      stopAutoplay();
    });
    window.addEventListener("pointerup", () => {
      slider.classList.remove("is-dragging");
    });

    document.addEventListener("visibilitychange", () => {
      if (document.hidden) stopAutoplay();
      else if (!animating && ready) startAutoplay();
    });

    let resizeTimer;
    window.addEventListener("resize", () => {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        if (!animating) positionAll(true);
      }, 80);
    });
  }

  /* --------------------------------------------------------------------
     Intro + loader
     -------------------------------------------------------------------- */
  async function preload() {
    const urls = PROJECTS.flatMap((p) => [p.imageLeft, p.imageRight]);
    let done = 0;
    const total = urls.length;
    const failed = [];

    await Promise.all(
      urls.map(async (src) => {
        const result = await loadImage(src);
        if (!result.ok) failed.push(src);
        done += 1;
        if (loaderBar) {
          gsap.to(loaderBar, {
            scaleX: done / total,
            duration: 0.25,
            ease: "power2.out",
            overwrite: true,
          });
        }
      })
    );

    if (failed.length) {
      console.warn("[Abbos slider] Some images failed to preload:", failed);
    }
  }

  function intro() {
    const chrome = [brand, countEl].filter(Boolean);

    gsap.set(chrome, { autoAlpha: 0, y: 10 });
    gsap.set([trackLeft, trackRight], { autoAlpha: 0 });

    // Align at rest before reveal
    positionAll(true);

    const tl = gsap.timeline({
      defaults: { ease: "power3.out" },
      onComplete: () => {
        ready = true;
        startKenBurns(0);
        startAutoplay();
      },
    });

    // Dismiss loader
    if (loader) {
      tl.to(loader, {
        autoAlpha: 0,
        duration: 0.55,
        ease: "power2.inOut",
        onComplete: () => loader.classList.add("is-done"),
      });
    }

    // Split entrance — halves meet from opposite offsets
    tl.fromTo(
      leftSlides[0],
      { y: 60 },
      { y: 0, duration: 1.1, ease: "power4.out" },
      0.2
    );
    tl.fromTo(
      rightSlides[0],
      { y: -60 },
      { y: 0, duration: 1.1, ease: "power4.out" },
      0.2
    );
    tl.to(
      [trackLeft, trackRight],
      { autoAlpha: 1, duration: 0.6 },
      0.15
    );

    // Content fade up
    const firstContent = [
      leftSlides[0].querySelector(".slide__content"),
      rightSlides[0].querySelector(".slide__content"),
    ];
    tl.fromTo(
      firstContent,
      { y: 28, opacity: 0 },
      { y: 0, opacity: 1, duration: 0.9, ease: "power3.out" },
      0.45
    );

    // Chrome
    tl.to(
      chrome,
      { autoAlpha: 1, y: 0, duration: 0.7, stagger: 0.06 },
      0.55
    );
  }

  /* --------------------------------------------------------------------
     Boot
     -------------------------------------------------------------------- */
  async function init() {
    if (typeof gsap === "undefined") {
      console.error("GSAP failed to load.");
      return;
    }

    gsap.config({ force3D: true, nullTargetWarn: false });
    if (typeof Observer !== "undefined") {
      gsap.registerPlugin(Observer);
    }

    build();
    bindObserver();

    try {
      await preload();
    } catch (_) {
      /* continue even if some images fail */
    }

    intro();
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();
````
