# Image Preview Cursor Follower

> Project list rows that drive a floating image preview with continuous vertical wipe transitions — portfolio and case-study navigation.

**Definition:** Image Preview Cursor Follower is a hover 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: Hover
- Stack: HTML, CSS, JavaScript, and GSAP
- Added: 2026-08-03
- Canonical page: https://html.bosconvertpro.com/collection/image-preview-cursor-follower
- Full-page preview: https://html.bosconvertpro.com/collection/image-preview-cursor-follower/full
- Live demo: https://html.bosconvertpro.com/demos/image-preview-cursor-follower/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/image-preview-cursor-follower/index.md
- License: https://html.bosconvertpro.com/terms

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

## FAQ
### What is Image Preview Cursor Follower?

Image Preview Cursor Follower is a hover UI animation from collections.html. Project list rows that drive a floating image preview with continuous vertical wipe transitions — portfolio and case-study navigation.

### What is Image Preview Cursor Follower built with?

Image Preview Cursor Follower 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 Image Preview Cursor Follower in my project?

Open the animation page (https://html.bosconvertpro.com/collection/image-preview-cursor-follower), 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/image-preview-cursor-follower/index.html.

### Is Image Preview Cursor Follower free to use?

Yes. The Image Preview Cursor Follower 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="Image Preview Cursor Follower — portfolio list with a GSAP image preview that follows the cursor"
    />
    <title>Image Preview Cursor Follower</title>

    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link rel="preconnect" href="https://cdn.jsdelivr.net" />
    <link rel="preconnect" href="https://images.unsplash.com" crossorigin />
    <link rel="dns-prefetch" href="https://images.unsplash.com" />

    <!-- Preload Unsplash visuals -->
    <link
      rel="preload"
      as="image"
      href="https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?auto=format&fit=crop&w=900&q=80"
      fetchpriority="high"
    />
    <link
      rel="preload"
      as="image"
      href="https://images.unsplash.com/photo-1541643600914-78b084683601?auto=format&fit=crop&w=900&q=80"
    />
    <link
      rel="preload"
      as="image"
      href="https://images.unsplash.com/photo-1490750967868-88aa4486c946?auto=format&fit=crop&w=900&q=80"
    />
    <link
      rel="preload"
      as="image"
      href="https://images.unsplash.com/photo-1555041469-a586c61ea9bc?auto=format&fit=crop&w=900&q=80"
    />

    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <main class="stage">
      <!--
        Image Preview Cursor Follower
        Hover a project row — a floating image preview follows the cursor.
        Vertical wipe transitions depend on list direction (GSAP).
      -->
      <div
        class="preview-follower"
        data-preview-follower
        aria-label="Featured work"
      >
        <!-- Column headers (desktop) -->
        <div class="preview-follower__header" aria-hidden="true">
          <div class="preview-follower__row">
            <div class="preview-follower__col preview-follower__col--name">
              <span class="preview-follower__col-label">Project</span>
            </div>
            <div class="preview-follower__col preview-follower__col--location">
              <span class="preview-follower__col-label">City</span>
            </div>
            <div class="preview-follower__col preview-follower__col--year">
              <span class="preview-follower__col-label">Year</span>
            </div>
            <div class="preview-follower__col preview-follower__col--services">
              <span class="preview-follower__col-label">Focus</span>
            </div>
          </div>
        </div>

        <!-- Project collection -->
        <div
          class="preview-follower__collection"
          data-preview-follower-collection
        >
          <div class="preview-follower__list" role="list">
            <!-- Aurora -->
            <div
              class="preview-follower__item"
              data-preview-follower-item
              role="listitem"
            >
              <a href="#" class="preview-follower__link">
                <div class="preview-follower__row">
                  <div class="preview-follower__col preview-follower__col--name">
                    <h2 class="preview-follower__heading">Aurora</h2>
                  </div>
                  <div
                    class="preview-follower__col preview-follower__col--location"
                  >
                    <p class="preview-follower__text">Copenhagen</p>
                  </div>
                  <div class="preview-follower__col preview-follower__col--year">
                    <p class="preview-follower__text">2026</p>
                  </div>
                  <div
                    class="preview-follower__col preview-follower__col--services"
                  >
                    <p class="preview-follower__text">Brand, Motion</p>
                  </div>
                </div>

                <div
                  class="preview-follower__visual"
                  data-preview-follower-visual
                >
                  <img
                    class="preview-follower__visual-img"
                    src="https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?auto=format&fit=crop&w=900&q=80"
                    alt="Aurora project visual — abstract fluid forms"
                    width="900"
                    height="1125"
                    draggable="false"
                    loading="eager"
                    decoding="async"
                    fetchpriority="high"
                  />
                </div>
              </a>
            </div>

            <!-- Lumen -->
            <div
              class="preview-follower__item"
              data-preview-follower-item
              role="listitem"
            >
              <a href="#" class="preview-follower__link">
                <div class="preview-follower__row">
                  <div class="preview-follower__col preview-follower__col--name">
                    <h2 class="preview-follower__heading">Lumen</h2>
                  </div>
                  <div
                    class="preview-follower__col preview-follower__col--location"
                  >
                    <p class="preview-follower__text">Lisbon</p>
                  </div>
                  <div class="preview-follower__col preview-follower__col--year">
                    <p class="preview-follower__text">2025</p>
                  </div>
                  <div
                    class="preview-follower__col preview-follower__col--services"
                  >
                    <p class="preview-follower__text">Product, UI</p>
                  </div>
                </div>

                <div
                  class="preview-follower__visual"
                  data-preview-follower-visual
                >
                  <img
                    class="preview-follower__visual-img"
                    src="https://images.unsplash.com/photo-1541643600914-78b084683601?auto=format&fit=crop&w=900&q=80"
                    alt="Lumen project visual — perfume bottle"
                    width="900"
                    height="1125"
                    draggable="false"
                    loading="eager"
                    decoding="async"
                    fetchpriority="high"
                  />
                </div>
              </a>
            </div>

            <!-- Solace -->
            <div
              class="preview-follower__item"
              data-preview-follower-item
              role="listitem"
            >
              <a href="#" class="preview-follower__link">
                <div class="preview-follower__row">
                  <div class="preview-follower__col preview-follower__col--name">
                    <h2 class="preview-follower__heading">Solace</h2>
                  </div>
                  <div
                    class="preview-follower__col preview-follower__col--location"
                  >
                    <p class="preview-follower__text">Milan</p>
                  </div>
                  <div class="preview-follower__col preview-follower__col--year">
                    <p class="preview-follower__text">2025</p>
                  </div>
                  <div
                    class="preview-follower__col preview-follower__col--services"
                  >
                    <p class="preview-follower__text">Art Direction</p>
                  </div>
                </div>

                <div
                  class="preview-follower__visual"
                  data-preview-follower-visual
                >
                  <img
                    class="preview-follower__visual-img"
                    src="https://images.unsplash.com/photo-1490750967868-88aa4486c946?auto=format&fit=crop&w=900&q=80"
                    alt="Solace project visual — floral arrangement"
                    width="900"
                    height="1125"
                    draggable="false"
                    loading="eager"
                    decoding="async"
                    fetchpriority="high"
                  />
                </div>
              </a>
            </div>

            <!-- Harbor -->
            <div
              class="preview-follower__item"
              data-preview-follower-item
              role="listitem"
            >
              <a href="#" class="preview-follower__link">
                <div class="preview-follower__row">
                  <div class="preview-follower__col preview-follower__col--name">
                    <h2 class="preview-follower__heading">Harbor</h2>
                  </div>
                  <div
                    class="preview-follower__col preview-follower__col--location"
                  >
                    <p class="preview-follower__text">Seoul</p>
                  </div>
                  <div class="preview-follower__col preview-follower__col--year">
                    <p class="preview-follower__text">2024</p>
                  </div>
                  <div
                    class="preview-follower__col preview-follower__col--services"
                  >
                    <p class="preview-follower__text">Strategy, Web</p>
                  </div>
                </div>

                <div
                  class="preview-follower__visual"
                  data-preview-follower-visual
                >
                  <img
                    class="preview-follower__visual-img"
                    src="https://images.unsplash.com/photo-1555041469-a586c61ea9bc?auto=format&fit=crop&w=900&q=80"
                    alt="Harbor project visual — green furniture"
                    width="900"
                    height="1125"
                    draggable="false"
                    loading="eager"
                    decoding="async"
                    fetchpriority="high"
                  />
                </div>
              </a>
            </div>
          </div>
        </div>

        <!-- Floating cursor preview (desktop only) -->
        <div
          class="preview-follower__cursor"
          data-preview-follower-cursor
          aria-hidden="true"
        >
          <div
            class="preview-follower__cursor-inner"
            data-preview-follower-cursor-inner
          >
            <!-- Image layers only — isolated stacking so they never cover the label -->
            <div
              class="preview-follower__media"
              data-preview-follower-media
            ></div>

            <!-- Always above media; never competes with layer z-index -->
            <div class="preview-follower__cursor-label">
              <span class="preview-follower__cursor-label-text">Explore</span>
            </div>
          </div>
        </div>
      </div>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
/* ─────────────────────────────────────────────
   Image Preview Cursor Follower
   Portfolio list with a floating image preview
   that follows the cursor (GSAP + directional wipe)
───────────────────────────────────────────── */

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

:root {
  /* Premium light editorial palette */
  --color-bg: #f7f5f2;
  --color-bg-elevated: #fbfaf8;
  --color-ink: #141311;
  --color-ink-muted: rgba(20, 19, 17, 0.48);
  --color-line: rgba(20, 19, 17, 0.28);
  --color-line-soft: rgba(20, 19, 17, 0.2);
  --color-cursor-label-bg: #ffffff;
  --font-sans: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  --ease-reveal: cubic-bezier(0.65, 0.1, 0, 1);
  --preview-width: 20em;
  --preview-radius: 0;
}

html,
body {
  width: 100%;
  min-height: 100%;
}

body {
  font-family: var(--font-sans);
  background: var(--color-bg);
  color: var(--color-ink);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
}

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

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

/* ── Stage ─────────────────────────────────── */

.stage {
  width: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 4rem 0;
  background:
    radial-gradient(
      ellipse 85% 55% at 50% 0%,
      rgba(255, 255, 255, 0.85) 0%,
      transparent 58%
    ),
    radial-gradient(
      ellipse 60% 40% at 80% 100%,
      rgba(236, 232, 224, 0.55) 0%,
      transparent 50%
    ),
    var(--color-bg);
}

/* ── Root component ────────────────────────── */

.preview-follower {
  width: 100%;
  max-width: 76em;
  margin-left: auto;
  margin-right: auto;
  padding-left: 2em;
  padding-right: 2em;
  position: relative;
}

/* ── Header row ────────────────────────────── */

.preview-follower__header {
  width: 100%;
}

.preview-follower__header .preview-follower__row {
  padding-bottom: 0.5em;
}

.preview-follower__col-label {
  color: var(--color-ink-muted);
  text-transform: uppercase;
  font-size: 0.75em;
  font-weight: 500;
  letter-spacing: 0.04em;
}

/* ── Collection / list ─────────────────────── */

.preview-follower__collection {
  width: 100%;
  margin-top: 0.5em;
}

.preview-follower__list {
  display: flex;
  flex-direction: column;
  width: 100%;
  position: relative;
}

/* ── Item ──────────────────────────────────── */

.preview-follower__item {
  width: 100%;
  transition: opacity 0.2s ease;
}

.preview-follower__link {
  display: block;
  width: 100%;
  border-top: 1px solid var(--color-line-soft);
  padding-top: 2.5em;
  padding-bottom: 2.5em;
  cursor: pointer;
}

.preview-follower__item:last-of-type .preview-follower__link {
  border-bottom: 1px solid var(--color-line);
}

/* ── Row / columns ─────────────────────────── */

.preview-follower__row {
  display: flex;
  flex-flow: wrap;
  justify-content: flex-start;
  align-items: center;
  width: 100%;
}

.preview-follower__col {
  flex: 1;
  min-width: 0;
}

.preview-follower__col--name {
  max-width: 45%;
}

.preview-follower__col--location,
.preview-follower__col--year {
  max-width: 15%;
}

.preview-follower__col--services {
  max-width: 25%;
}

/* ── Typography ────────────────────────────── */

.preview-follower__heading {
  margin: 0;
  font-size: 3.5em;
  font-weight: 400;
  line-height: 1;
  letter-spacing: -0.02em;
}

.preview-follower__text {
  margin: 0;
  font-size: 1.25em;
  font-weight: 400;
  line-height: 1.2;
}

/* ── Inline visual (hidden on desktop) ─────── */

.preview-follower__visual {
  aspect-ratio: 1 / 1.25;
  width: var(--preview-width);
  display: none;
  position: absolute;
  overflow: hidden;
  border-radius: 0;
}

.preview-follower__visual-img {
  object-fit: cover;
  width: 100%;
  height: 100%;
}

/* ── Floating cursor preview ───────────────── */

.preview-follower__cursor {
  z-index: 100;
  aspect-ratio: 1 / 1.25;
  pointer-events: none;
  border-radius: 0;
  justify-content: center;
  align-items: center;
  width: var(--preview-width);
  display: flex;
  position: fixed;
  top: 0;
  left: 0;
  overflow: hidden;
  will-change: transform;
}

.preview-follower__cursor-inner {
  z-index: 2;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  display: flex;
  position: relative;
  opacity: 0;
  transform: scale(0);
  transition:
    opacity 0.1s ease,
    transform 0.6s var(--ease-reveal);
  will-change: transform, opacity;
  overflow: hidden;
}

/*
  Media stack: own stacking context so GSAP layer z-index
  can never paint above the Explore label.
*/
.preview-follower__media {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  isolation: isolate;
  border-radius: 0;
  pointer-events: none;
}

/*
  Permanent image layers inside the media stack only.
  Do NOT set CSS transform here — GSAP owns yPercent / force3D.
*/
.preview-follower__layer {
  display: block;
  width: 100%;
  height: 100%;
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  border-radius: 0;
  pointer-events: none;
  will-change: transform;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

.preview-follower__layer-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 0;
  pointer-events: none;
  -webkit-user-drag: none;
  user-select: none;
}

/*
  Explore label: sibling of media, always on top.
  Never animated away; never shares z-index with image layers.
*/
.preview-follower__cursor-label {
  position: absolute;
  inset: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 1;
  visibility: visible;
  transform: none;
  pointer-events: none;
}

.preview-follower__cursor-label-text {
  display: block;
  position: relative;
  z-index: 1;
  background-color: var(--color-cursor-label-bg);
  border-radius: 0;
  padding: 0.75em 1.25em;
  font-size: 1em;
  font-weight: 500;
  color: var(--color-ink);
  white-space: nowrap;
  box-shadow: 0 4px 20px rgba(20, 19, 17, 0.08);
}

/* ── Hover interactions (desktop + hover) ──── */

@media (hover: hover) and (min-width: 992px) {
  body:has([data-preview-follower-collection]:hover)
    .preview-follower__cursor-inner {
    opacity: 1;
    transform: scale(1);
  }

  body:has(.preview-follower__item:hover)
    .preview-follower__item:not(:hover) {
    opacity: 0.5;
  }
}

/* ── Tablet ────────────────────────────────── */

@media screen and (max-width: 991px) {
  .preview-follower__header {
    display: none;
  }

  .preview-follower__list {
    flex-flow: wrap;
    column-gap: 1em;
    row-gap: 4em;
  }

  .preview-follower__item {
    width: calc(50% - 0.5em);
  }

  .preview-follower__link {
    border: none;
    border-top: none;
    padding-top: 0;
    padding-bottom: 0;
    display: flex;
    flex-flow: column;
  }

  .preview-follower__item:last-of-type .preview-follower__link {
    border-bottom: none;
  }

  .preview-follower__row {
    row-gap: 0.5em;
  }

  .preview-follower__col--name {
    flex: none;
    order: -1;
    width: 100%;
    max-width: none;
  }

  .preview-follower__col--location {
    display: none;
  }

  .preview-follower__col--year {
    text-align: right;
    max-width: 20%;
  }

  .preview-follower__col--services {
    order: -1;
    max-width: 80%;
  }

  .preview-follower__heading {
    font-size: 2em;
  }

  .preview-follower__visual {
    border-radius: 0;
    order: -1;
    width: 100%;
    margin-bottom: 1em;
    display: block;
    position: relative;
  }

  .preview-follower__cursor {
    display: none;
  }
}

/* ── Mobile ────────────────────────────────── */

@media screen and (max-width: 767px) {
  .stage {
    padding: 2.5rem 0;
  }

  .preview-follower {
    padding-left: 1em;
    padding-right: 1em;
  }

  .preview-follower__list {
    row-gap: 3em;
  }

  .preview-follower__item {
    width: 100%;
  }
}

/* ── Reduced motion ────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  .preview-follower__item,
  .preview-follower__cursor-inner,
  .preview-follower__cursor-label {
    transition: none !important;
  }

  .preview-follower__cursor {
    display: none;
  }

  .preview-follower__visual {
    display: block;
    position: relative;
    width: 100%;
    max-width: 20em;
    margin-bottom: 1em;
  }
}
````

## Source — script.js
````javascript
/**
 * Image Preview Cursor Follower
 *
 * Floating image preview that follows the cursor over a project list.
 * Vertical wipe transitions stay continuous even when the pointer moves fast.
 *
 * Data attributes:
 *   [data-preview-follower]              — root wrap
 *   [data-preview-follower-collection]   — list container
 *   [data-preview-follower-item]         — each project row
 *   [data-preview-follower-visual]       — image source
 *   [data-preview-follower-cursor]       — fixed follower element
 *   [data-preview-follower-cursor-inner] — inner scale/reveal shell
 */
(function () {
  "use strict";

  const CONFIG = {
    offset: 100,
    duration: 0.45,
    ease: "power2.inOut",
    followDuration: 0.6,
    followEase: "power3",
  };

  /* --------------------------------------------------------------------------
   * Boot
   * -------------------------------------------------------------------------- */

  function waitForGsap(callback, attempt) {
    attempt = attempt || 0;
    if (typeof gsap !== "undefined") {
      callback();
      return;
    }
    if (attempt < 150) {
      setTimeout(function () {
        waitForGsap(callback, attempt + 1);
      }, 50);
      return;
    }
    console.error(
      "[Image Preview Cursor Follower] GSAP failed to load from CDN."
    );
  }

  function preloadImages(root) {
    const imgs = Array.from(
      root.querySelectorAll("[data-preview-follower-visual] img")
    );

    imgs.forEach(function (img) {
      if (img.complete && img.naturalWidth > 0) return;
      const warm = new Image();
      warm.decoding = "async";
      warm.src = img.currentSrc || img.src;
    });
  }

  function init() {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      return;
    }

    if (!window.matchMedia("(hover: hover) and (min-width: 992px)").matches) {
      return;
    }

    const wrappers = document.querySelectorAll("[data-preview-follower]");
    if (!wrappers.length) return;

    const instances = [];

    wrappers.forEach(function (wrap) {
      const instance = new PreviewFollower(wrap);
      if (instance.isReady) instances.push(instance);
      preloadImages(wrap);
    });

    window.PreviewFollowerAPI = { instances: instances };
  }

  /* --------------------------------------------------------------------------
   * PreviewFollower
   * -------------------------------------------------------------------------- */

  class PreviewFollower {
    /**
     * @param {HTMLElement} wrap
     */
    constructor(wrap) {
      this.wrap = wrap;
      this.collection = wrap.querySelector("[data-preview-follower-collection]");
      this.items = Array.from(
        wrap.querySelectorAll("[data-preview-follower-item]")
      );
      this.cursor = wrap.querySelector("[data-preview-follower-cursor]");
      this.cursorInner = wrap.querySelector(
        "[data-preview-follower-cursor-inner]"
      );
      this.media = wrap.querySelector("[data-preview-follower-media]");

      /** @type {HTMLElement[]} */
      this.layers = [];
      this.currentIndex = -1;
      this.zBoost = 1;
      this.isReady = false;

      this.boundMouseMove = this.onMouseMove.bind(this);

      if (
        !this.collection ||
        !this.cursor ||
        !this.cursorInner ||
        !this.media ||
        !this.items.length
      ) {
        return;
      }

      this.setup();
      this.isReady = true;
    }

    setup() {
      gsap.set(this.cursor, {
        xPercent: -50,
        yPercent: -50,
        force3D: true,
      });

      this.xTo = gsap.quickTo(this.cursor, "x", {
        duration: CONFIG.followDuration,
        ease: CONFIG.followEase,
      });
      this.yTo = gsap.quickTo(this.cursor, "y", {
        duration: CONFIG.followDuration,
        ease: CONFIG.followEase,
      });

      // Layers live only inside the media stack (isolated from the label)
      this.layers = this.items
        .map(
          function (item, index) {
            const source = item.querySelector("[data-preview-follower-visual]");
            if (!source) return null;

            const layer = document.createElement("div");
            layer.className = "preview-follower__layer";
            layer.setAttribute("data-preview-follower-layer", String(index));

            const img = document.createElement("img");
            img.className = "preview-follower__layer-img";
            img.alt = "";
            img.draggable = false;
            img.decoding = "async";
            img.loading = "eager";

            const sourceImg = source.querySelector("img");
            if (sourceImg) {
              img.src = sourceImg.currentSrc || sourceImg.src;
            }

            layer.appendChild(img);
            this.media.appendChild(layer);

            // Park below the mask (media overflow:hidden clips it)
            gsap.set(layer, {
              yPercent: CONFIG.offset,
              zIndex: 0,
              force3D: true,
            });

            return layer;
          }.bind(this)
        )
        .filter(Boolean);

      this.bindEvents();
    }

    bindEvents() {
      window.addEventListener("mousemove", this.boundMouseMove, {
        passive: true,
      });

      this.items.forEach(
        function (item, index) {
          item.addEventListener(
            "mouseenter",
            function () {
              this.showLayer(index);
            }.bind(this)
          );
        }.bind(this)
      );

      this.collection.addEventListener(
        "mouseleave",
        function () {
          this.hideAll();
        }.bind(this)
      );
    }

    onMouseMove(event) {
      this.xTo(event.clientX);
      this.yTo(event.clientY);
    }

    /**
     * Read live yPercent without forcing layout.
     * @param {HTMLElement} layer
     * @returns {number}
     */
    getY(layer) {
      return Number(gsap.getProperty(layer, "yPercent")) || 0;
    }

    /**
     * Continuously retarget layers — never kill + snap mid-flight.
     * That is what caused the "stop" when moving the pointer quickly.
     * @param {number} index
     */
    showLayer(index) {
      if (index === this.currentIndex) return;
      if (!this.layers[index]) return;

      const prevIndex = this.currentIndex;
      const isFirst = prevIndex === -1;
      const forward = isFirst || index > prevIndex;
      const next = this.layers[index];
      const exitY = forward ? -CONFIG.offset : CONFIG.offset;
      const enterY = forward ? CONFIG.offset : -CONFIG.offset;

      this.currentIndex = index;
      this.zBoost += 1;
      // Invalidate any hideAll park callbacks
      this.leaveGeneration = (this.leaveGeneration || 0) + 1;

      // ── 1) Every non-active layer keeps sliding out from its CURRENT y ──
      //    No killTweensOf + set() — overwrite: "auto" retargets in place.
      this.layers.forEach(
        function (layer, i) {
          if (i === index) return;

          const y = this.getY(layer);
          // Already fully parked off-screen on the exit side → leave it
          if (Math.abs(y - exitY) < 0.5) return;
          // Fully off on the enter side and not moving in → park on exit side quietly
          if (Math.abs(y) >= CONFIG.offset - 0.5 && Math.abs(y - enterY) < 0.5) {
            gsap.set(layer, { yPercent: exitY, zIndex: 0, force3D: true });
            return;
          }

          gsap.to(layer, {
            yPercent: exitY,
            duration: CONFIG.duration,
            ease: CONFIG.ease,
            overwrite: "auto",
            force3D: true,
          });
        }.bind(this)
      );

      // ── 2) Active layer enters ──────────────────────────────────────────
      gsap.set(next, { zIndex: this.zBoost, force3D: true });

      if (isFirst) {
        // First open: sit centered; shell scale is CSS
        gsap.set(next, { yPercent: 0, force3D: true });
        return;
      }

      const currentY = this.getY(next);
      const mostlyOff = Math.abs(currentY) >= CONFIG.offset * 0.92;

      // Only snap start position when already clipped off-screen (invisible)
      if (mostlyOff) {
        gsap.set(next, { yPercent: enterY, force3D: true });
      }
      // else: layer is partially visible — keep currentY and retarget to 0

      gsap.to(next, {
        yPercent: 0,
        duration: CONFIG.duration,
        ease: CONFIG.ease,
        overwrite: "auto",
        force3D: true,
      });
    }

    hideAll() {
      const leaveGeneration = (this.leaveGeneration =
        (this.leaveGeneration || 0) + 1);
      const active =
        this.currentIndex >= 0 ? this.layers[this.currentIndex] : null;

      this.currentIndex = -1;

      this.layers.forEach(
        function (layer) {
          const isActive = layer === active;
          gsap.to(layer, {
            yPercent: -CONFIG.offset,
            duration: isActive ? CONFIG.duration * 0.85 : CONFIG.duration * 0.5,
            ease: CONFIG.ease,
            overwrite: "auto",
            force3D: true,
            onComplete: function () {
              // Skip if user re-entered the list mid-exit
              if (this.leaveGeneration !== leaveGeneration) return;
              if (this.currentIndex !== -1) return;
              gsap.set(layer, {
                yPercent: CONFIG.offset,
                zIndex: 0,
                force3D: true,
              });
            }.bind(this),
          });
        }.bind(this)
      );
    }

    destroy() {
      window.removeEventListener("mousemove", this.boundMouseMove);
      gsap.killTweensOf(this.cursor);
      this.layers.forEach(function (layer) {
        gsap.killTweensOf(layer);
        layer.remove();
      });
      this.layers = [];
      this.currentIndex = -1;
    }
  }

  /* --------------------------------------------------------------------------
   * Start
   * -------------------------------------------------------------------------- */

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