# Image Folllow List

> List rows that spawn a floating image preview following the cursor with velocity tilt — expressive catalog and portfolio navigation.

**Definition:** Image Folllow List 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-folllow-list
- Full-page preview: https://html.bosconvertpro.com/collection/image-folllow-list/full
- Live demo: https://html.bosconvertpro.com/demos/image-folllow-list/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/image-folllow-list/index.md
- License: https://html.bosconvertpro.com/terms

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

## FAQ
### What is Image Folllow List?

Image Folllow List is a hover UI animation from collections.html. List rows that spawn a floating image preview following the cursor with velocity tilt — expressive catalog and portfolio navigation.

### What is Image Folllow List built with?

Image Folllow List 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 Folllow List in my project?

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

### Is Image Folllow List free to use?

Yes. The Image Folllow List 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>Image Follow List</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" />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <main class="stage">
      <header class="stage__header">
        <p class="stage__eyebrow">Collections</p>
        <h1 class="stage__title">Explore the line</h1>
      </header>

      <!--
        Image Follow List
        Hover a row — a large floating preview follows the cursor
        with velocity-based tilt (GSAP).
      -->
      <div
        class="image-follow-list"
        data-image-follow-list
        data-image-size="253"
        data-position="top"
        data-duration="0.55"
        data-follow-speed="0.55"
        data-max-rotation="18"
        aria-label="Product collections"
      >
        <ul class="image-follow-list__list" role="list">
          <li
            class="image-follow-list__item"
            data-image-follow-list-item
            tabindex="0"
          >
            <span
              class="image-follow-list__divider image-follow-list__divider--top"
              aria-hidden="true"
            ></span>
            <span class="image-follow-list__index" aria-hidden="true">01</span>
            <span class="image-follow-list__label" data-image-follow-list-label>
              Running Collection
            </span>
            <span class="image-follow-list__meta">Performance</span>
            <span class="image-follow-list__divider" aria-hidden="true"></span>
          </li>

          <li
            class="image-follow-list__item"
            data-image-follow-list-item
            tabindex="0"
          >
            <span class="image-follow-list__index" aria-hidden="true">02</span>
            <span class="image-follow-list__label" data-image-follow-list-label>
              Cycling Gear
            </span>
            <span class="image-follow-list__meta">Road &amp; Trail</span>
            <span class="image-follow-list__divider" aria-hidden="true"></span>
          </li>

          <li
            class="image-follow-list__item"
            data-image-follow-list-item
            tabindex="0"
          >
            <span class="image-follow-list__index" aria-hidden="true">03</span>
            <span class="image-follow-list__label" data-image-follow-list-label>
              Performance Apparel
            </span>
            <span class="image-follow-list__meta">Essentials</span>
            <span class="image-follow-list__divider" aria-hidden="true"></span>
          </li>

          <li
            class="image-follow-list__item"
            data-image-follow-list-item
            tabindex="0"
          >
            <span class="image-follow-list__index" aria-hidden="true">04</span>
            <span class="image-follow-list__label" data-image-follow-list-label>
              Training Accessories
            </span>
            <span class="image-follow-list__meta">Studio</span>
            <span class="image-follow-list__divider" aria-hidden="true"></span>
          </li>

          <li
            class="image-follow-list__item"
            data-image-follow-list-item
            tabindex="0"
          >
            <span class="image-follow-list__index" aria-hidden="true">05</span>
            <span class="image-follow-list__label" data-image-follow-list-label>
              Winter Sports
            </span>
            <span class="image-follow-list__meta">Alpine</span>
            <span class="image-follow-list__divider" aria-hidden="true"></span>
          </li>

          <li
            class="image-follow-list__item"
            data-image-follow-list-item
            tabindex="0"
          >
            <span class="image-follow-list__index" aria-hidden="true">06</span>
            <span class="image-follow-list__label" data-image-follow-list-label>
              Recovery &amp; Wellness
            </span>
            <span class="image-follow-list__meta">Care</span>
            <span class="image-follow-list__divider" aria-hidden="true"></span>
          </li>
        </ul>

        <!-- Floating preview — sharp corners, large editorial crop -->
        <div
          class="image-follow-list__preview"
          data-image-follow-list-preview
          aria-hidden="true"
        >
          <img
            class="image-follow-list__image"
            data-image-follow-list-image
            data-item-index="0"
            src="https://images.unsplash.com/photo-1476480862126-209bfaa8edc8?auto=format&fit=crop&w=1200&q=85"
            alt=""
            draggable="false"
            loading="eager"
          />
          <img
            class="image-follow-list__image"
            data-image-follow-list-image
            data-item-index="1"
            src="https://images.unsplash.com/photo-1541625602330-2277a4c46182?auto=format&fit=crop&w=1200&q=85"
            alt=""
            draggable="false"
            loading="lazy"
          />
          <img
            class="image-follow-list__image"
            data-image-follow-list-image
            data-item-index="2"
            src="https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1200&q=85"
            alt=""
            draggable="false"
            loading="lazy"
          />
          <img
            class="image-follow-list__image"
            data-image-follow-list-image
            data-item-index="3"
            src="https://images.unsplash.com/photo-1517836357463-d25dfeac3438?auto=format&fit=crop&w=1200&q=85"
            alt=""
            draggable="false"
            loading="lazy"
          />
          <img
            class="image-follow-list__image"
            data-image-follow-list-image
            data-item-index="4"
            src="https://images.unsplash.com/photo-1551698618-1dfe5d97d256?auto=format&fit=crop&w=1200&q=85"
            alt=""
            draggable="false"
            loading="lazy"
          />
          <img
            class="image-follow-list__image"
            data-image-follow-list-image
            data-item-index="5"
            src="https://images.unsplash.com/photo-1545205597-3d9d02c29597?auto=format&fit=crop&w=1200&q=85"
            alt=""
            draggable="false"
            loading="lazy"
          />
        </div>
      </div>
    </main>

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

## Source — styles.css
````css
/* ─────────────────────────────────────────────
   Image Follow List — premium light editorial
   Floating Unsplash preview follows the cursor
───────────────────────────────────────────── */

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

:root {
  --color-bg: #f7f5f2;
  --color-bg-elevated: #fbfaf8;
  --color-ink: #141311;
  --color-ink-soft: #3f3c38;
  --color-ink-muted: #5c5852;
  --color-line: rgba(20, 19, 17, 0.12);
  --color-line-strong: rgba(20, 19, 17, 0.22);
  --color-focus: #c8d5b9;
  --font-sans: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}

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

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

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

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

.stage {
  width: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: center;
  padding: 5rem 0;
  background:
    radial-gradient(
      ellipse 80% 60% at 50% 0%,
      rgba(255, 255, 255, 0.7) 0%,
      transparent 55%
    ),
    var(--color-bg);
}

.stage__header {
  width: 100%;
  max-width: none;
  margin: 0 0 2.75rem;
  padding: 0 4vw;
  text-align: left;
}

.stage__eyebrow {
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-ink-soft);
  margin-bottom: 0.75rem;
}

.stage__title {
  font-size: clamp(1.85rem, 3.5vw, 2.625rem);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.2;
  color: var(--color-ink);
}

/* ── Image Follow List ─────────────────────── */

.image-follow-list {
  --ifl-image-size: 253px;
  --ifl-border: var(--color-line);
  --ifl-accent: var(--color-ink);
  --ifl-label-size: clamp(1.35rem, 2.4vw, 1.875rem);
  --ifl-item-pad-y: 1.85rem;
  --ifl-item-pad-x: 0.25rem;
  --ifl-item-pad-hover: 1.5rem;
  --ifl-ease: var(--ease-out);

  position: relative;
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: none;
  margin: 0;
  padding: 0 4vw;
}

.image-follow-list__list {
  display: flex;
  flex-direction: column;
  list-style: none;
  margin: 0;
  padding: 0;
  width: 100%;
}

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

.image-follow-list__item {
  position: relative;
  display: grid;
  grid-template-columns: 3rem 1fr auto;
  align-items: baseline;
  gap: 1.25rem 1.5rem;
  padding: var(--ifl-item-pad-y) var(--ifl-item-pad-x);
  padding-left: var(--ifl-item-pad-x);
  cursor: pointer;
  overflow: hidden;
  transform: translateZ(0);
  will-change: transform, padding-left;
  color: var(--ifl-accent);
  transition:
    color 400ms var(--ifl-ease),
    padding-left 400ms var(--ifl-ease),
    opacity 400ms var(--ifl-ease);
  margin-top: -1px;
  outline: none;
}

.image-follow-list__list:hover .image-follow-list__item:not(:hover):not(.is-active) {
  opacity: 0.55;
}

.image-follow-list__item:hover,
.image-follow-list__item.is-active {
  padding-left: var(--ifl-item-pad-hover);
  opacity: 1;
}

.image-follow-list__item:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 6px;
}

/* ── Index + meta ──────────────────────────── */

.image-follow-list__index {
  font-size: 0.875rem;
  font-weight: 500;
  letter-spacing: 0.04em;
  color: var(--color-ink-soft);
  line-height: 1.3;
  align-self: center;
  pointer-events: none;
  user-select: none;
  transition: color 400ms var(--ifl-ease);
}

.image-follow-list__item:hover .image-follow-list__index,
.image-follow-list__item.is-active .image-follow-list__index {
  color: var(--color-ink);
}

.image-follow-list__meta {
  font-size: 0.875rem;
  font-weight: 500;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--color-ink-soft);
  line-height: 1.3;
  align-self: center;
  pointer-events: none;
  user-select: none;
  white-space: nowrap;
  transition: color 400ms var(--ifl-ease);
}

.image-follow-list__item:hover .image-follow-list__meta,
.image-follow-list__item.is-active .image-follow-list__meta {
  color: var(--color-ink);
}

/* ── Divider ───────────────────────────────── */

.image-follow-list__divider {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  background-color: var(--ifl-border);
  z-index: 2;
  pointer-events: none;
}

.image-follow-list__divider--top {
  top: 0;
  bottom: auto;
}

.image-follow-list__item:hover .image-follow-list__divider,
.image-follow-list__item.is-active .image-follow-list__divider {
  background-color: var(--color-line-strong);
}

/* ── Label ─────────────────────────────────── */

.image-follow-list__label {
  position: relative;
  z-index: 1;
  display: block;
  font-size: var(--ifl-label-size);
  font-weight: 500;
  letter-spacing: -0.015em;
  line-height: 1.35;
  color: var(--color-ink);
  transform: translateZ(0);
  pointer-events: none;
  user-select: none;
}

/* ── Floating preview (no rounding) ────────── */

.image-follow-list__preview {
  position: fixed;
  top: 0;
  left: 0;
  width: var(--ifl-image-size);
  aspect-ratio: 4 / 5;
  pointer-events: none;
  z-index: 9999;
  background: #e8e4de;
  border-radius: 0;
  overflow: hidden;
  opacity: 0;
  will-change: transform, opacity;
  box-shadow:
    0 28px 80px rgba(28, 27, 25, 0.14),
    0 8px 24px rgba(28, 27, 25, 0.06);
  transform-origin: center center;
}

.image-follow-list__image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 0;
  opacity: 0;
  will-change: transform, opacity;
  pointer-events: none;
}

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

@media (prefers-reduced-motion: reduce) {
  .image-follow-list__item,
  .image-follow-list__label,
  .image-follow-list__preview,
  .image-follow-list__image {
    animation: none !important;
    transition: none !important;
  }

  .image-follow-list__item:hover,
  .image-follow-list__item.is-active {
    padding-left: var(--ifl-item-pad-x);
  }

  .image-follow-list__list:hover .image-follow-list__item {
    opacity: 1;
  }

  .image-follow-list__preview {
    display: none;
  }
}

/* ── High contrast ─────────────────────────── */

@media (prefers-contrast: high) {
  :root {
    --color-line: rgba(0, 0, 0, 0.45);
    --color-ink-muted: #333;
  }

  .image-follow-list__item:hover .image-follow-list__label,
  .image-follow-list__item.is-active .image-follow-list__label {
    text-decoration: underline;
  }
}

/* ── Responsive ────────────────────────────── */

@media (max-width: 991px) {
  .stage {
    padding: 3.5rem 0;
  }

  .stage__header {
    margin-bottom: 2rem;
    padding: 0 5vw;
  }

  .image-follow-list {
    --ifl-image-size: 200px;
    --ifl-item-pad-y: 1.45rem;
    --ifl-item-pad-hover: 1.15rem;
    padding: 0 5vw;
  }

  .image-follow-list__item {
    grid-template-columns: 2.25rem 1fr auto;
    gap: 0.85rem 1rem;
  }
}

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

  .stage__header {
    padding: 0 6vw;
  }

  .image-follow-list {
    --ifl-item-pad-y: 1.2rem;
    --ifl-item-pad-x: 0;
    --ifl-item-pad-hover: 0.75rem;
    padding: 0 6vw;
  }

  .image-follow-list__item {
    grid-template-columns: 2rem 1fr;
    gap: 0.5rem 0.85rem;
  }

  .image-follow-list__meta {
    display: none;
  }

  .image-follow-list__preview {
    display: none;
  }
}
````

## Source — script.js
````javascript
/**
 * Image Follow List
 * Floating preview that follows the cursor over list rows,
 * with velocity-based tilt and GSAP transitions.
 */
(function () {
  "use strict";

  /* --------------------------------------------------------------------------
   * Defaults — override via data attributes on [data-image-follow-list]
   * -------------------------------------------------------------------------- */

  const DEFAULTS = {
    duration: 0.55,
    ease: "expo.out",
    position: "top", // top | right | bottom | left
    maxRotation: 18,
    imageSize: 253,
    followSpeed: 0.55,
    rotationDecay: 0.92,
    velocityMultiplier: 0.45,
  };

  /* --------------------------------------------------------------------------
   * 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 Follow List] GSAP failed to load from CDN.");
  }

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

    const roots = document.querySelectorAll("[data-image-follow-list]");
    if (!roots.length) return;

    const instances = [];

    roots.forEach(function (root) {
      if (shouldDisable(root)) return;
      if (isTouchDevice()) return;

      const config = readConfig(root);
      const instance = new ImageFollowList(root, config);
      if (instance.isReady) instances.push(instance);
    });

    document.addEventListener("visibilitychange", function () {
      if (document.hidden) {
        instances.forEach(function (inst) {
          inst.pauseLoop();
        });
      } else {
        instances.forEach(function (inst) {
          inst.resumeLoop();
        });
      }
    });

    window.ImageFollowListAPI = {
      instances: instances,
      refresh: init,
    };
  }

  /* --------------------------------------------------------------------------
   * Helpers
   * -------------------------------------------------------------------------- */

  function isTouchDevice() {
    return (
      "ontouchstart" in window ||
      navigator.maxTouchPoints > 0 ||
      window.matchMedia("(hover: none)").matches
    );
  }

  function shouldDisable(root) {
    const disable = root.getAttribute("data-disable");
    if (!disable) return false;

    const queries = {
      mobile: "(max-width: 479px)",
      tablet: "(max-width: 991px)",
      landscape: "(orientation: landscape) and (max-width: 767px)",
      desktop: "(min-width: 992px)",
    };

    return disable.split(",").some(function (token) {
      const key = token.trim();
      return queries[key] && window.matchMedia(queries[key]).matches;
    });
  }

  function readAttr(el, name, fallback) {
    const raw = el.getAttribute("data-" + name);
    if (raw === null) return fallback;
    if (raw === "true") return true;
    if (raw === "false") return false;
    const num = parseFloat(raw);
    return isNaN(num) ? raw : num;
  }

  function offsetsForPosition(position, imageSize) {
    const height = imageSize * 1.25; // 4:5 editorial preview
    switch (position) {
      case "right":
        return { offsetX: 12, offsetY: -height / 2 };
      case "bottom":
        return { offsetX: -imageSize / 2, offsetY: 12 };
      case "left":
        return { offsetX: -(imageSize + 12), offsetY: -height / 2 };
      case "top":
      default:
        return { offsetX: -imageSize / 2, offsetY: -(height + 16) };
    }
  }

  function readConfig(root) {
    const imageSize = readAttr(root, "image-size", DEFAULTS.imageSize);
    const position = readAttr(root, "position", DEFAULTS.position);
    const auto = offsetsForPosition(position, imageSize);

    const offsetXAttr = root.getAttribute("data-offset-x");
    const offsetYAttr = root.getAttribute("data-offset-y");

    return {
      duration: readAttr(root, "duration", DEFAULTS.duration),
      ease: readAttr(root, "ease", DEFAULTS.ease),
      position: position,
      offsetX: offsetXAttr !== null ? parseFloat(offsetXAttr) : auto.offsetX,
      offsetY: offsetYAttr !== null ? parseFloat(offsetYAttr) : auto.offsetY,
      maxRotation: readAttr(root, "max-rotation", DEFAULTS.maxRotation),
      imageSize: imageSize,
      followSpeed: readAttr(root, "follow-speed", DEFAULTS.followSpeed),
      rotationDecay: DEFAULTS.rotationDecay,
      velocityMultiplier: DEFAULTS.velocityMultiplier,
    };
  }

  /* --------------------------------------------------------------------------
   * ImageFollowList class
   * -------------------------------------------------------------------------- */

  class ImageFollowList {
    /**
     * @param {HTMLElement} root
     * @param {object} config
     */
    constructor(root, config) {
      this.root = root;
      this.config = config;
      this.items = Array.from(
        root.querySelectorAll("[data-image-follow-list-item]")
      );
      this.preview = root.querySelector("[data-image-follow-list-preview]");
      this.images = Array.from(
        root.querySelectorAll("[data-image-follow-list-image]")
      );

      this.pos = { x: 0, y: 0 };
      this.prevPos = { x: 0, y: 0 };
      this.velocity = { x: 0, y: 0 };
      this.rotation = 0;
      this.targetRotation = 0;
      this.rafId = null;
      this.lastTime = 0;
      this.currentIndex = -1;
      this.isHovering = false;
      this.isReady = false;

      this.boundMouseMove = this.onMouseMove.bind(this);
      this.boundMouseEnter = this.onMouseEnter.bind(this);
      this.boundMouseLeave = this.onMouseLeave.bind(this);

      if (!this.preview || !this.images.length || !this.items.length) {
        return;
      }

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

    setup() {
      this.root.style.setProperty(
        "--ifl-image-size",
        this.config.imageSize + "px"
      );

      this.images.forEach(function (img) {
        gsap.set(img, { opacity: 0, scale: 0, force3D: true });
      });

      gsap.set(this.preview, {
        opacity: 0,
        scale: 0,
        xPercent: 0,
        yPercent: 0,
        force3D: true,
      });

      this.xTo = gsap.quickTo(this.preview, "x", {
        duration: this.config.followSpeed,
        ease: "power3.out",
      });
      this.yTo = gsap.quickTo(this.preview, "y", {
        duration: this.config.followSpeed,
        ease: "power3.out",
      });

      this.bindEvents();
      this.startVelocityLoop();
    }

    bindEvents() {
      this.root.addEventListener("mousemove", this.boundMouseMove);
      this.root.addEventListener("mouseenter", this.boundMouseEnter);
      this.root.addEventListener("mouseleave", this.boundMouseLeave);

      // Keyboard focus shows matching image without cursor follow
      this.items.forEach(
        function (item, index) {
          item.addEventListener(
            "focus",
            function () {
              this.activateItem(index, false);
            }.bind(this)
          );
          item.addEventListener(
            "blur",
            function () {
              if (!this.isHovering) this.hidePreview();
            }.bind(this)
          );
        }.bind(this)
      );
    }

    onMouseMove(event) {
      this.pos.x = event.clientX + this.config.offsetX;
      this.pos.y = event.clientY + this.config.offsetY;
      this.movePreview();

      const index = this.getHoveredIndex(event.clientX, event.clientY);
      if (index !== -1 && index !== this.currentIndex) {
        this.showImage(index);
      }
    }

    onMouseEnter() {
      // Keep preview alive while re-entering list bounds
    }

    onMouseLeave() {
      this.hidePreview();
    }

    getHoveredIndex(clientX, clientY) {
      for (let i = 0; i < this.items.length; i++) {
        const rect = this.items[i].getBoundingClientRect();
        if (
          clientY >= rect.top &&
          clientY <= rect.bottom &&
          clientX >= rect.left &&
          clientX <= rect.right
        ) {
          return i;
        }
      }
      return -1;
    }

    startVelocityLoop() {
      const tick = function (time) {
        if (!this.lastTime) this.lastTime = time;
        const delta = time - this.lastTime;
        this.lastTime = time;

        const dx = this.pos.x - this.prevPos.x;
        const dy = this.pos.y - this.prevPos.y;

        if (delta > 0) {
          this.velocity.x = this.velocity.x * 0.7 + dx * 0.3;
          this.velocity.y = this.velocity.y * 0.7 + dy * 0.3;
        }

        this.prevPos.x = this.pos.x;
        this.prevPos.y = this.pos.y;

        const raw = this.velocity.x * this.config.velocityMultiplier;
        this.targetRotation = Math.max(
          -this.config.maxRotation,
          Math.min(this.config.maxRotation, raw)
        );

        if (this.isHovering) {
          this.rotation += (this.targetRotation - this.rotation) * 0.2;
        } else {
          this.rotation *= this.config.rotationDecay;
        }

        if (this.preview) {
          gsap.set(this.preview, { rotation: this.rotation });
        }

        this.rafId = requestAnimationFrame(tick);
      }.bind(this);

      this.rafId = requestAnimationFrame(tick);
    }

    pauseLoop() {
      if (this.rafId) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
    }

    resumeLoop() {
      if (!this.rafId) {
        this.lastTime = 0;
        this.startVelocityLoop();
      }
    }

    movePreview() {
      if (!this.isHovering) return;
      this.xTo(this.pos.x);
      this.yTo(this.pos.y);
    }

    /**
     * @param {number} index
     * @param {boolean} [followCursor=true]
     */
    activateItem(index, followCursor) {
      if (followCursor === undefined) followCursor = true;
      this.items.forEach(function (item, i) {
        item.classList.toggle("is-active", i === index);
      });
      this.showImage(index, followCursor);
    }

    /**
     * @param {number} index
     * @param {boolean} [fromPointer=true]
     */
    showImage(index, fromPointer) {
      if (fromPointer === undefined) fromPointer = true;

      const wasHovering = this.isHovering;
      this.currentIndex = index;
      this.isHovering = true;

      this.items.forEach(function (item, i) {
        item.classList.toggle("is-active", i === index);
      });

      // First reveal — snap under cursor then scale in
      if (!wasHovering && fromPointer) {
        gsap.set(this.preview, { x: this.pos.x, y: this.pos.y });
        gsap.fromTo(
          this.preview,
          { opacity: 0, scale: 0.5 },
          {
            opacity: 1,
            scale: 1,
            duration: this.config.duration * 0.6,
            ease: "back.out(1.7)",
            force3D: true,
          }
        );
      } else if (!wasHovering) {
        // Keyboard: center over the focused row
        const rect = this.items[index].getBoundingClientRect();
        const x =
          rect.right -
          this.config.imageSize -
          16 +
          (this.config.offsetX > 0 ? this.config.offsetX : 0);
        const y =
          rect.top +
          rect.height / 2 -
          this.config.imageSize * 0.625;
        gsap.set(this.preview, { x: x, y: y });
        this.pos.x = x;
        this.pos.y = y;
        gsap.fromTo(
          this.preview,
          { opacity: 0, scale: 0.5 },
          {
            opacity: 1,
            scale: 1,
            duration: this.config.duration * 0.6,
            ease: "back.out(1.7)",
            force3D: true,
          }
        );
      }

      // Hide non-active images
      this.images.forEach(function (img, i) {
        if (i !== index) {
          gsap.killTweensOf(img);
          gsap.set(img, { opacity: 0, scale: 0.5 });
        }
      });

      const active = this.images[index];
      if (!active) return;

      gsap.killTweensOf(active);
      gsap.fromTo(
        active,
        { opacity: 0, scale: 0.5 },
        {
          opacity: 1,
          scale: 1,
          duration: this.config.duration * 0.5,
          ease: "back.out(1.4)",
          force3D: true,
        }
      );
    }

    hidePreview() {
      this.isHovering = false;
      this.currentIndex = -1;

      this.items.forEach(function (item) {
        item.classList.remove("is-active");
      });

      gsap.to(this.preview, {
        opacity: 0,
        scale: 0,
        duration: this.config.duration * 0.6,
        ease: this.config.ease,
        force3D: true,
      });

      this.images.forEach(
        function (img) {
          gsap.to(img, {
            opacity: 0,
            scale: 0,
            duration: this.config.duration * 0.4,
            ease: this.config.ease,
            force3D: true,
          });
        }.bind(this)
      );
    }

    destroy() {
      this.pauseLoop();
      this.root.removeEventListener("mousemove", this.boundMouseMove);
      this.root.removeEventListener("mouseenter", this.boundMouseEnter);
      this.root.removeEventListener("mouseleave", this.boundMouseLeave);
      gsap.killTweensOf(this.preview);
      this.images.forEach(function (img) {
        gsap.killTweensOf(img);
      });
    }
  }

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

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