# Spatial Cards Slider (GSAP)

> A draggable 3D spatial card slider with GSAP inertia, curved depth, and an inverted-curve variant — premium product and editorial carousels.

**Definition:** Spatial Cards Slider (GSAP) 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-30
- Canonical page: https://html.bosconvertpro.com/collection/spatial-cards-slider-gsap
- Full-page preview: https://html.bosconvertpro.com/collection/spatial-cards-slider-gsap/full
- Live demo: https://html.bosconvertpro.com/demos/spatial-cards-slider-gsap/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/spatial-cards-slider-gsap/index.md
- License: https://html.bosconvertpro.com/terms

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

## FAQ
### What is Spatial Cards Slider (GSAP)?

Spatial Cards Slider (GSAP) is a sliders UI animation from collections.html. A draggable 3D spatial card slider with GSAP inertia, curved depth, and an inverted-curve variant — premium product and editorial carousels.

### What is Spatial Cards Slider (GSAP) built with?

Spatial Cards Slider (GSAP) 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 Spatial Cards Slider (GSAP) in my project?

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

### Is Spatial Cards Slider (GSAP) free to use?

Yes. The Spatial Cards Slider (GSAP) 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>Spatial Cards Slider (GSAP)</title>
    <link rel="preconnect" href="https://images.unsplash.com" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Outfit:wght@400;500;600&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lenis@1.2.3/dist/lenis.css" />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <main class="main">
      <section class="demo-section">
        <div
          data-spatial-slider-init
          data-spatial-slider-drag-status="grab"
          class="spatial-gsap-slider"
        >
          <div data-spatial-slider-collection class="spatial-gsap-slider__collection">
            <div data-spatial-slider-list class="spatial-gsap-slider__list">
              <div
                data-spatial-slider-item
                data-spatial-slider-item-status="active"
                class="spatial-gsap-slider__item"
              >
                <div class="demo-card">
                  <div class="demo-card__media">
                    <img
                      class="cover-image"
                      src="https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?auto=format&fit=crop&w=900&q=82"
                      alt="Portrait in warm studio light"
                      loading="eager"
                      draggable="false"
                    />
                  </div>
                  <div class="demo-card__info">
                    <h3 class="demo-card__h">Solace</h3>
                  </div>
                </div>
              </div>

              <div
                data-spatial-slider-item
                data-spatial-slider-item-status="inview"
                class="spatial-gsap-slider__item"
              >
                <div class="demo-card">
                  <div class="demo-card__media">
                    <img
                      class="cover-image"
                      src="https://images.unsplash.com/photo-1519710164239-da123dc03ef4?auto=format&fit=crop&w=900&q=82"
                      alt="Natural wood furniture and grain"
                      loading="lazy"
                      draggable="false"
                    />
                  </div>
                  <div class="demo-card__info">
                    <h3 class="demo-card__h">Grain</h3>
                  </div>
                </div>
              </div>

              <div
                data-spatial-slider-item
                data-spatial-slider-item-status="inview"
                class="spatial-gsap-slider__item"
              >
                <div class="demo-card">
                  <div class="demo-card__media">
                    <img
                      class="cover-image"
                      src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=900&q=82"
                      alt="Snow-capped mountain peaks"
                      loading="lazy"
                      draggable="false"
                    />
                  </div>
                  <div class="demo-card__info">
                    <h3 class="demo-card__h">Summit</h3>
                  </div>
                </div>
              </div>

              <div
                data-spatial-slider-item
                data-spatial-slider-item-status="inview"
                class="spatial-gsap-slider__item"
              >
                <div class="demo-card">
                  <div class="demo-card__media">
                    <img
                      class="cover-image"
                      src="https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=900&q=82"
                      alt="Turquoise shoreline at dusk"
                      loading="lazy"
                      draggable="false"
                    />
                  </div>
                  <div class="demo-card__info">
                    <h3 class="demo-card__h">Shore</h3>
                  </div>
                </div>
              </div>

              <div
                data-spatial-slider-item
                data-spatial-slider-item-status="inview"
                class="spatial-gsap-slider__item"
              >
                <div class="demo-card">
                  <div class="demo-card__media">
                    <img
                      class="cover-image"
                      src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=900&q=82"
                      alt="Sunlit forest path"
                      loading="lazy"
                      draggable="false"
                    />
                  </div>
                  <div class="demo-card__info">
                    <h3 class="demo-card__h">Canopy</h3>
                  </div>
                </div>
              </div>
            </div>
          </div>

          <div class="spatial-gsap-slider__controls">
            <button
              type="button"
              data-spatial-slider-control="prev"
              class="spatial-gsap-slider__control-btn"
            >
              <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path
                  d="M14.5 6.5 9 12l5.5 5.5"
                  stroke="currentColor"
                  stroke-width="1.8"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                />
              </svg>
            </button>
            <div data-spatial-slider-generate-dots class="spatial-gsap-slider__dots">
              <button
                type="button"
                data-spatial-slider-control="1"
                data-spatial-slider-control-status="active"
                class="spatial-gsap-slider__control-dot"
              ></button>
            </div>
            <button
              type="button"
              data-spatial-slider-control="next"
              class="spatial-gsap-slider__control-btn is--next"
            >
              <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path
                  d="M9.5 6.5 15 12l-5.5 5.5"
                  stroke="currentColor"
                  stroke-width="1.8"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                />
              </svg>
            </button>
          </div>
        </div>

        <p class="note">Scroll for the inverted curve</p>
      </section>

      <section class="demo-section">
        <div
          data-spatial-slider-init
          data-spatial-slider-drag-status="grab"
          class="spatial-gsap-slider"
        >
          <div data-spatial-slider-collection class="spatial-gsap-slider__collection">
            <div data-spatial-slider-list class="spatial-gsap-slider__list">
              <div
                data-spatial-slider-item
                data-spatial-slider-item-status="active"
                class="spatial-gsap-slider__item"
              >
                <div class="demo-card">
                  <div class="demo-card__media">
                    <img
                      class="cover-image"
                      src="https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?auto=format&fit=crop&w=900&q=82"
                      alt="Portrait in warm studio light"
                      loading="lazy"
                      draggable="false"
                    />
                  </div>
                  <div class="demo-card__info">
                    <h3 class="demo-card__h">Solace</h3>
                  </div>
                </div>
              </div>

              <div
                data-spatial-slider-item
                data-spatial-slider-item-status="inview"
                class="spatial-gsap-slider__item"
              >
                <div class="demo-card">
                  <div class="demo-card__media">
                    <img
                      class="cover-image"
                      src="https://images.unsplash.com/photo-1519710164239-da123dc03ef4?auto=format&fit=crop&w=900&q=82"
                      alt="Natural wood furniture and grain"
                      loading="lazy"
                      draggable="false"
                    />
                  </div>
                  <div class="demo-card__info">
                    <h3 class="demo-card__h">Grain</h3>
                  </div>
                </div>
              </div>

              <div
                data-spatial-slider-item
                data-spatial-slider-item-status="inview"
                class="spatial-gsap-slider__item"
              >
                <div class="demo-card">
                  <div class="demo-card__media">
                    <img
                      class="cover-image"
                      src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=900&q=82"
                      alt="Snow-capped mountain peaks"
                      loading="lazy"
                      draggable="false"
                    />
                  </div>
                  <div class="demo-card__info">
                    <h3 class="demo-card__h">Summit</h3>
                  </div>
                </div>
              </div>

              <div
                data-spatial-slider-item
                data-spatial-slider-item-status="inview"
                class="spatial-gsap-slider__item"
              >
                <div class="demo-card">
                  <div class="demo-card__media">
                    <img
                      class="cover-image"
                      src="https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=900&q=82"
                      alt="Turquoise shoreline at dusk"
                      loading="lazy"
                      draggable="false"
                    />
                  </div>
                  <div class="demo-card__info">
                    <h3 class="demo-card__h">Shore</h3>
                  </div>
                </div>
              </div>

              <div
                data-spatial-slider-item
                data-spatial-slider-item-status="inview"
                class="spatial-gsap-slider__item"
              >
                <div class="demo-card">
                  <div class="demo-card__media">
                    <img
                      class="cover-image"
                      src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=900&q=82"
                      alt="Sunlit forest path"
                      loading="lazy"
                      draggable="false"
                    />
                  </div>
                  <div class="demo-card__info">
                    <h3 class="demo-card__h">Canopy</h3>
                  </div>
                </div>
              </div>
            </div>
          </div>

          <div class="spatial-gsap-slider__controls">
            <button
              type="button"
              data-spatial-slider-control="prev"
              class="spatial-gsap-slider__control-btn"
            >
              <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path
                  d="M14.5 6.5 9 12l5.5 5.5"
                  stroke="currentColor"
                  stroke-width="1.8"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                />
              </svg>
            </button>
            <div data-spatial-slider-generate-dots class="spatial-gsap-slider__dots">
              <button
                type="button"
                data-spatial-slider-control="1"
                data-spatial-slider-control-status="active"
                class="spatial-gsap-slider__control-dot"
              ></button>
            </div>
            <button
              type="button"
              data-spatial-slider-control="next"
              class="spatial-gsap-slider__control-btn is--next"
            >
              <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path
                  d="M9.5 6.5 15 12l-5.5 5.5"
                  stroke="currentColor"
                  stroke-width="1.8"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                />
              </svg>
            </button>
          </div>
        </div>
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/lenis@1.2.3/dist/lenis.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/Draggable.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/InertiaPlugin.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/CustomEase.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
:root {
  --size-unit: 16;
  --size-container-ideal: 1440;
  --size-container-min: 992px;
  --size-container-max: 1920px;
  --size-container: clamp(
    var(--size-container-min),
    100vw,
    var(--size-container-max)
  );
  --size-font: calc(
    var(--size-container) / (var(--size-container-ideal) / var(--size-unit))
  );
  --color-bg: #f3eee6;
  --color-card: #fffcf8;
  --color-ink: #1f1b16;
  --color-muted: #8a7e72;
  --color-dot: #c9bfb3;
  --color-line: rgba(31, 27, 22, 0.08);
  --color-shadow: 0 1.25em 2.5em rgba(31, 27, 22, 0.08);
  --font-ui: "Outfit", system-ui, sans-serif;
  --font-display: "Cormorant Garamond", "Times New Roman", serif;
}

@media screen and (max-width: 991px) {
  :root {
    --size-container-ideal: 834;
    --size-container-min: 768px;
    --size-container-max: 991px;
  }
}

@media screen and (max-width: 767px) {
  :root {
    --size-container-ideal: 550;
    --size-container-min: 480px;
    --size-container-max: 767px;
  }
}

@media screen and (max-width: 479px) {
  :root {
    --size-container-ideal: 390;
    --size-container-min: 320px;
    --size-container-max: 479px;
  }
}

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

html {
  scrollbar-width: none;
}

html,
body {
  margin: 0;
  padding: 0;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body::-webkit-scrollbar,
body ::-webkit-scrollbar {
  display: none;
}

body {
  color: var(--color-ink);
  background-color: var(--color-bg);
  font-family: var(--font-ui);
  font-size: var(--size-font);
  font-weight: 400;
  line-height: 1.25;
  -ms-overflow-style: none;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  margin: 0;
  padding: 0;
  appearance: none;
  -webkit-appearance: none;
}

img {
  max-width: 100%;
  display: block;
  -webkit-user-drag: none;
}

.main {
  overflow: clip;
}

.demo-section {
  position: relative;
  display: flex;
  min-height: 100dvh;
  background-color: var(--color-bg);
}

[data-spatial-slider-init] {
  --slider-gap: 2.5em;
  --slider-curve: 30deg;
  --slider-perspective: 120em;
  --slider-direction: 1;
}

.demo-section + .demo-section [data-spatial-slider-init] {
  --slider-curve: 20deg;
  --slider-direction: -1;
}

.spatial-gsap-slider {
  position: relative;
  display: flex;
  flex-flow: column;
  justify-content: center;
  align-items: center;
  width: 100%;
  padding-top: 10em;
  padding-bottom: 5em;
  row-gap: 5em;
}

[data-spatial-slider-drag-status="grab"] {
  cursor: grab;
}

[data-spatial-slider-drag-status="grabbing"] {
  cursor: grabbing;
}

.spatial-gsap-slider__collection {
  position: relative;
  max-width: 100%;
}

.spatial-gsap-slider__list {
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
  max-width: 100%;
  column-gap: var(--slider-gap);
  perspective: var(--slider-perspective);
  transform-style: preserve-3d;
  backface-visibility: hidden;
  user-select: none;
  -webkit-user-select: none;
  touch-action: pan-y;
  will-change: transform;
}

.spatial-gsap-slider__item {
  position: absolute;
  flex: none;
  transform-style: preserve-3d;
  backface-visibility: hidden;
  will-change: transform;
}

[data-spatial-slider-list] > :first-child {
  position: relative;
}

.demo-card {
  display: flex;
  flex-flow: column;
  align-items: flex-start;
  width: 18em;
  padding-top: 0.625em;
  padding-left: 0.625em;
  padding-right: 0.625em;
  color: var(--color-ink);
  text-align: center;
  background-color: var(--color-card);
  border: 1px solid var(--color-line);
  border-radius: 1.375em;
  box-shadow: var(--color-shadow);
}

.demo-card__media {
  position: relative;
  width: 100%;
  aspect-ratio: 1;
  border-radius: 0.75em;
  overflow: hidden;
}

.cover-image {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
  pointer-events: none;
}

.demo-card__info {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 3.5em;
  padding-left: 0.75em;
  padding-right: 0.75em;
}

.demo-card__h {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.45em;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.spatial-gsap-slider__controls {
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
  column-gap: 1em;
  padding-left: 3em;
  padding-right: 3em;
}

.spatial-gsap-slider__control-btn {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  width: 3em;
  height: 3em;
  padding: 0;
  color: var(--color-ink);
  background-color: var(--color-card);
  border: 1px solid var(--color-line);
  border-radius: 50%;
  box-shadow: 0 0.35em 1em rgba(31, 27, 22, 0.06);
  cursor: pointer;
}

.spatial-gsap-slider__control-btn svg {
  width: 1.15em;
  height: 1.15em;
  display: block;
}

.spatial-gsap-slider__control-btn:focus-visible,
.spatial-gsap-slider__control-dot:focus-visible {
  outline: 2px solid var(--color-ink);
  outline-offset: 3px;
}

.spatial-gsap-slider__dots {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 0.4em;
}

.spatial-gsap-slider__control-dot {
  position: relative;
  z-index: 1;
  flex: none;
  width: 0.5em;
  height: 0.5em;
  padding: 0;
  color: var(--color-dot);
  background-color: currentColor;
  border: 0;
  border-radius: 50em;
  overflow: hidden;
  cursor: pointer;
  transition:
    width 0.55s cubic-bezier(0.25, 0.1, 0, 1),
    color 0.45s cubic-bezier(0.25, 0.1, 0, 1),
    background-color 0.45s cubic-bezier(0.25, 0.1, 0, 1);
}

.spatial-gsap-slider__control-dot[data-spatial-slider-control-status="active"] {
  width: 1.75em;
  color: var(--color-ink);
}

.note {
  position: absolute;
  bottom: 2em;
  left: 0;
  width: 100%;
  margin: 0;
  color: var(--color-muted);
  font-weight: 400;
  letter-spacing: 0.04em;
  text-align: center;
}

@media screen and (max-width: 767px) {
  [data-spatial-slider-init] {
    --slider-gap: 1em;
    --slider-curve: 45deg;
  }

  .demo-card {
    width: 15em;
  }

  .spatial-gsap-slider {
    padding-top: 6em;
    padding-bottom: 4em;
    row-gap: 3em;
  }
}
````

## Source — script.js
````javascript
gsap.registerPlugin(Draggable, InertiaPlugin, CustomEase);

CustomEase.create("spatial", "0.25, 0.1, 0, 1");

function initSpatialCardsSlider() {
  const slideDuration = 1;
  const clickEase = "spatial";

  document.querySelectorAll("[data-spatial-slider-init]").forEach((container) => {
    if (container._spatialSliderDraggable) container._spatialSliderDraggable.kill();
    if (container._spatialSliderImageObserver) {
      container._spatialSliderImageObserver.disconnect();
    }

    if (container._spatialSliderProxy) {
      gsap.killTweensOf(container._spatialSliderProxy);
      container._spatialSliderProxy.remove();
    }

    const collection = container.querySelector("[data-spatial-slider-collection]");
    const track = container.querySelector("[data-spatial-slider-list]");
    if (!collection || !track) return;

    gsap.set(track, { clearProps: "transform" });

    container.querySelectorAll("[data-spatial-slider-item]").forEach((item) => {
      gsap.set(item, { clearProps: "transform" });
    });

    container.querySelectorAll("[data-spatial-slider-clone]").forEach((el) => el.remove());

    const originalItems = Array.from(
      track.querySelectorAll(":scope > [data-spatial-slider-item]:not([data-spatial-slider-clone])")
    );
    if (!originalItems.length) return;

    container.setAttribute("role", "region");
    container.setAttribute("aria-roledescription", "carousel");
    container.setAttribute(
      "aria-label",
      container.getAttribute("aria-label") || "Spatial Cards Slider"
    );
    track.setAttribute("role", "group");
    track.setAttribute("aria-label", "Slides");

    const dotsWrap = container.querySelector("[data-spatial-slider-generate-dots]");

    if (dotsWrap) {
      const dots = Array.from(dotsWrap.querySelectorAll("[data-spatial-slider-control]"));

      if (dots.length) {
        const template = dots[0];
        dots.slice(1).forEach((dot) => dot.remove());

        for (let i = 1; i <= originalItems.length; i++) {
          const dot = i === 1 ? template : template.cloneNode(true);
          dot.setAttribute("data-spatial-slider-control", String(i));
          dot.setAttribute("data-spatial-slider-control-status", "not-active");
          if (i > 1) dotsWrap.appendChild(dot);
        }
      }
    }

    const controls = Array.from(container.querySelectorAll("[data-spatial-slider-control]"));
    const totalEl = container.querySelector("[data-spatial-slider-total-slide]");
    const indicators = Array.from(container.querySelectorAll("[data-spatial-slider-active-slide]"));
    const mod = (value, total) => ((value % total) + total) % total;
    const formatNumber = (value) => (value < 10 ? "0" + value : String(value));

    if (totalEl) totalEl.textContent = formatNumber(originalItems.length);

    originalItems.forEach((item, index) => {
      item.removeAttribute("data-spatial-slider-item-status");
      item.removeAttribute("aria-hidden");
      item.setAttribute("role", "group");
      item.setAttribute("aria-label", `Slide ${index + 1} of ${originalItems.length}`);
    });

    controls.forEach((btn) => {
      const value = btn.getAttribute("data-spatial-slider-control");

      if (value === "prev") btn.setAttribute("aria-label", "Previous slide");
      if (value === "next") btn.setAttribute("aria-label", "Next slide");

      if (/^\d+$/.test(value)) {
        btn.setAttribute("aria-label", `Go to slide ${value}`);
        btn.setAttribute("aria-current", "false");
      }
    });

    const containerStyles = getComputedStyle(container);
    const trackStyles = getComputedStyle(track);
    const curve = Math.abs(parseFloat(containerStyles.getPropertyValue("--slider-curve"))) || 12;
    const directionValue = parseFloat(containerStyles.getPropertyValue("--slider-direction"));
    const direction = directionValue < 0 ? -1 : 1;
    const gap = parseFloat(trackStyles.columnGap) || 0;
    const curveRadians = (curve * Math.PI) / 180;

    const firstRect = originalItems[0].getBoundingClientRect();
    const itemWidth = firstRect.width;
    const itemHeight = firstRect.height;

    const perspectiveValue = parseFloat(getComputedStyle(track).perspective);
    const perspective = Number.isFinite(perspectiveValue) ? perspectiveValue : 1200;

    const getProjectedEdgeX = (radius, angle, side) => {
      const radians = (angle * Math.PI) / 180;
      const rotation = -direction * radians;
      const localX = (side * itemWidth) / 2;

      const centerX = Math.sin(radians) * radius;
      const centerZ = direction * radius * (1 - Math.cos(radians));

      const x = centerX + localX * Math.cos(rotation);
      const z = centerZ - localX * Math.sin(rotation);

      return (x * perspective) / (perspective - z);
    };

    let spatialRadius = itemWidth / Math.sin(curveRadians);

    for (let i = 0; i < 8; i++) {
      const nextLeft = getProjectedEdgeX(spatialRadius, curve, -1);
      const currentRight = itemWidth / 2;
      const currentGap = nextLeft - currentRight;
      const correction = gap - currentGap;

      spatialRadius += correction / Math.sin(curveRadians);
    }

    const stepDistance = Math.sin(curveRadians) * spatialRadius;
    const tangentRatio = (-direction * spatialRadius) / (perspective - direction * spatialRadius);
    const edgeAngle = (Math.acos(gsap.utils.clamp(-1, 1, tangentRatio)) * 180) / Math.PI;
    const maxSideItems = Math.ceil(edgeAngle / curve);
    const maxLoopItems = maxSideItems * 2;

    const getSpatialPosition = (offset) => {
      const angle = gsap.utils.clamp(-edgeAngle, edgeAngle, offset * curve);
      const radians = (angle * Math.PI) / 180;

      return {
        x: Math.sin(radians) * spatialRadius,
        z: direction * spatialRadius * (1 - Math.cos(radians)),
        rotationY: -direction * angle,
      };
    };

    const containerRect = container.getBoundingClientRect();
    const trackRect = track.getBoundingClientRect();
    const originX = trackRect.left + trackRect.width / 2;
    const leftLimit = containerRect.left - originX;
    const rightLimit = containerRect.right - originX;

    const isOffsetInside = (offset) => {
      if (Math.abs(offset * curve) >= edgeAngle) return false;

      const position = getSpatialPosition(offset);
      const scale = perspective / (perspective - position.z);
      const radians = (Math.abs(position.rotationY) * Math.PI) / 180;
      const halfWidth = (Math.abs(Math.cos(radians)) * itemWidth * scale) / 2;
      const x = position.x * scale;

      return x + halfWidth >= leftLimit && x - halfWidth <= rightLimit;
    };

    const getVisibleCount = () => {
      let left = 0;
      let right = 0;

      for (let i = 1; i < maxSideItems && isOffsetInside(i); i++) right = i;
      for (let i = 1; i < maxSideItems && isOffsetInside(-i); i++) left = i;

      return Math.min(maxLoopItems, 1 + left + right + 2);
    };

    const minItemsNeeded = getVisibleCount();
    const neededItems =
      originalItems.length >= minItemsNeeded
        ? originalItems.length
        : Math.ceil(minItemsNeeded / originalItems.length) * originalItems.length;

    for (let i = originalItems.length; i < neededItems; i++) {
      const clone = originalItems[i % originalItems.length].cloneNode(true);
      clone.setAttribute("data-spatial-slider-clone", "");
      clone.setAttribute("aria-hidden", "true");
      track.appendChild(clone);
    }

    const items = Array.from(track.querySelectorAll(":scope > [data-spatial-slider-item]"));
    const totalItems = items.length;

    track.style.height = itemHeight + "px";
    container.setAttribute("data-spatial-slider-drag-status", "grab");

    items.forEach((item) => item.setAttribute("data-spatial-slider-item-status", "not-active"));

    const proxy = document.createElement("div");
    proxy.setAttribute("data-spatial-slider-proxy", "");

    Object.assign(proxy.style, {
      position: "absolute",
      width: "1px",
      height: "1px",
      pointerEvents: "none",
      opacity: "0",
    });

    container.appendChild(proxy);
    container._spatialSliderProxy = proxy;

    gsap.set(proxy, { x: 0 });

    const setX = items.map((item) => gsap.quickSetter(item, "x", "px"));
    const setZ = items.map((item) => gsap.quickSetter(item, "z", "px"));
    const setRotationY = items.map((item) => gsap.quickSetter(item, "rotationY", "deg"));

    const getIndex = () => -gsap.getProperty(proxy, "x") / stepDistance;

    const nearestDelta = (index, realIndex) => {
      const loop = Math.round((realIndex - index) / totalItems);
      return index - (realIndex - loop * totalItems);
    };

    const getSlideDelta = (target, realIndex) => {
      let bestDelta = 0;
      let bestDistance = Infinity;

      items.forEach((item, index) => {
        if (index % originalItems.length !== target) return;

        const delta = nearestDelta(index, realIndex);
        const distance = Math.abs(delta);

        if (distance < bestDistance) {
          bestDelta = delta;
          bestDistance = distance;
        }
      });

      return bestDelta;
    };

    let lastActiveIndex = null;

    const updateActiveUI = (activeIndex, activeSlideIndex) => {
      if (activeIndex === lastActiveIndex) return;

      items.forEach((item, index) => {
        item.setAttribute(
          "data-spatial-slider-item-status",
          index === activeIndex ? "active" : "inview"
        );
      });

      indicators.forEach((el) => {
        el.textContent = formatNumber(activeSlideIndex + 1);
      });

      controls.forEach((btn) => {
        const value = btn.getAttribute("data-spatial-slider-control");
        if (!/^\d+$/.test(value)) return;

        const isActive = parseInt(value, 10) - 1 === activeSlideIndex;
        btn.setAttribute(
          "data-spatial-slider-control-status",
          isActive ? "active" : "not-active"
        );
        btn.setAttribute("aria-current", isActive ? "true" : "false");
      });

      lastActiveIndex = activeIndex;
    };

    const render = () => {
      const realIndex = getIndex();
      const activeIndex = mod(Math.round(realIndex), totalItems);
      const activeSlideIndex = activeIndex % originalItems.length;

      items.forEach((item, index) => {
        const position = getSpatialPosition(nearestDelta(index, realIndex));

        setX[index](position.x);
        setZ[index](position.z);
        setRotationY[index](position.rotationY);
      });

      updateActiveUI(activeIndex, activeSlideIndex);
    };

    controls.forEach((btn) => {
      const value = btn.getAttribute("data-spatial-slider-control");
      btn.disabled = false;

      btn.onclick = () => {
        gsap.killTweensOf(proxy);

        const currentIndex = getIndex();
        let targetIndex;

        if (value === "next" || value === "prev") {
          targetIndex = Math.round(currentIndex) + (value === "next" ? 1 : -1);
        } else if (/^\d+$/.test(value)) {
          const targetSlide = Math.max(
            0,
            Math.min(originalItems.length - 1, parseInt(value, 10) - 1)
          );
          targetIndex = currentIndex + getSlideDelta(targetSlide, currentIndex);
        } else {
          return;
        }

        gsap.to(proxy, {
          x: -targetIndex * stepDistance,
          duration: slideDuration,
          ease: clickEase,
          onUpdate: render,
        });
      };
    });

    container._spatialSliderDraggable = Draggable.create(proxy, {
      type: "x",
      trigger: collection,
      inertia: true,
      throwResistance: 2000,
      dragResistance: 0.05,
      maxDuration: 1,
      minDuration: 0.5,
      edgeResistance: 0.75,
      overshootTolerance: 0,
      snap: (value) => Math.round(value / stepDistance) * stepDistance,
      onDrag: render,
      onThrowUpdate: render,
      onThrowComplete: () => {
        container.setAttribute("data-spatial-slider-drag-status", "grab");
        render();
      },
      onPress: () => container.setAttribute("data-spatial-slider-drag-status", "grabbing"),
      onDragStart: () => container.setAttribute("data-spatial-slider-drag-status", "grabbing"),
      onRelease: () => container.setAttribute("data-spatial-slider-drag-status", "grab"),
    })[0];

    render();

    container._spatialSliderImageObserver = new IntersectionObserver(([entry], observer) => {
      if (!entry.isIntersecting) return;
      container.querySelectorAll('[data-spatial-slider-item] img[loading="lazy"]').forEach((img) => {
        img.loading = "eager";
      });
      observer.disconnect();
    });
    container._spatialSliderImageObserver.observe(container);
  });

  if (initSpatialCardsSlider._resize) {
    window.removeEventListener("resize", initSpatialCardsSlider._resize);
  }

  initSpatialCardsSlider._resize = debounceOnWidthChange(initSpatialCardsSlider, 200);
  window.addEventListener("resize", initSpatialCardsSlider._resize);
}

function debounceOnWidthChange(fn, ms) {
  let lastWidth = window.innerWidth;
  let timer;

  return function (...args) {
    clearTimeout(timer);

    timer = setTimeout(() => {
      if (window.innerWidth === lastWidth) return;
      lastWidth = window.innerWidth;
      fn.apply(this, args);
    }, ms);
  };
}

document.addEventListener("DOMContentLoaded", () => {
  initSpatialCardsSlider();
});

if (typeof Lenis === "function") {
  const lenis = new Lenis({
    autoRaf: true,
  });
  window.lenis = lenis;
}
````
