# Rotating Image Trail

> Pointer-driven image trails that rotate and lag behind motion — playful interaction for creative landings.

**Definition:** Rotating Image Trail 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-01
- Canonical page: https://html.bosconvertpro.com/collection/rotating-image-trail
- Full-page preview: https://html.bosconvertpro.com/collection/rotating-image-trail/full
- Live demo: https://html.bosconvertpro.com/demos/rotating-image-trail/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/rotating-image-trail/index.md
- License: https://html.bosconvertpro.com/terms

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

## FAQ
### What is Rotating Image Trail?

Rotating Image Trail is a hover UI animation from collections.html. Pointer-driven image trails that rotate and lag behind motion — playful interaction for creative landings.

### What is Rotating Image Trail built with?

Rotating Image Trail 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 Rotating Image Trail in my project?

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

### Is Rotating Image Trail free to use?

Yes. The Rotating Image Trail 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>Rotating Image Trail</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://picsum.photos" />
    <link rel="preconnect" href="https://cdn.jsdelivr.net" />
    <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="app">
      <div
        data-trail-area
        class="rotating-image-trail"
        aria-label="Move your cursor to create a rotating image trail"
      >
        <!-- Source images (hidden). Clones are spawned into the trail area. -->
        <div data-trail-collection class="rotating-image-trail__collection" aria-hidden="true">
          <div class="rotating-image-trail__list">
            <div data-trail-item class="rotating-image-trail__item">
              <div class="rotating-image-trail__card">
                <img
                  class="rotating-image-trail__card-img"
                  src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=600&q=80"
                  data-fallback="https://picsum.photos/seed/trail-01/600/800"
                  alt=""
                  loading="eager"
                  draggable="false"
                />
              </div>
            </div>
            <div data-trail-item class="rotating-image-trail__item">
              <div class="rotating-image-trail__card">
                <img
                  class="rotating-image-trail__card-img"
                  src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=600&q=80"
                  data-fallback="https://picsum.photos/seed/trail-02/600/800"
                  alt=""
                  loading="eager"
                  draggable="false"
                />
              </div>
            </div>
            <div data-trail-item class="rotating-image-trail__item">
              <div class="rotating-image-trail__card">
                <img
                  class="rotating-image-trail__card-img"
                  src="https://images.unsplash.com/photo-1529626455594-4ff0802cfb7e?auto=format&fit=crop&w=600&q=80"
                  data-fallback="https://picsum.photos/seed/trail-03/600/800"
                  alt=""
                  loading="eager"
                  draggable="false"
                />
              </div>
            </div>
            <div data-trail-item class="rotating-image-trail__item">
              <div class="rotating-image-trail__card">
                <img
                  class="rotating-image-trail__card-img"
                  src="https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?auto=format&fit=crop&w=600&q=80"
                  data-fallback="https://picsum.photos/seed/trail-04/600/800"
                  alt=""
                  loading="eager"
                  draggable="false"
                />
              </div>
            </div>
            <div data-trail-item class="rotating-image-trail__item">
              <div class="rotating-image-trail__card">
                <img
                  class="rotating-image-trail__card-img"
                  src="https://images.unsplash.com/photo-1517841905240-472988babdf9?auto=format&fit=crop&w=600&q=80"
                  data-fallback="https://picsum.photos/seed/trail-05/600/800"
                  alt=""
                  loading="eager"
                  draggable="false"
                />
              </div>
            </div>
            <div data-trail-item class="rotating-image-trail__item">
              <div class="rotating-image-trail__card">
                <img
                  class="rotating-image-trail__card-img"
                  src="https://images.unsplash.com/photo-1488161628813-04466f872be2?auto=format&fit=crop&w=600&q=80"
                  data-fallback="https://picsum.photos/seed/trail-06/600/800"
                  alt=""
                  loading="eager"
                  draggable="false"
                />
              </div>
            </div>
            <div data-trail-item class="rotating-image-trail__item">
              <div class="rotating-image-trail__card">
                <img
                  class="rotating-image-trail__card-img"
                  src="https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=600&q=80"
                  data-fallback="https://picsum.photos/seed/trail-07/600/800"
                  alt=""
                  loading="eager"
                  draggable="false"
                />
              </div>
            </div>
            <div data-trail-item class="rotating-image-trail__item">
              <div class="rotating-image-trail__card">
                <img
                  class="rotating-image-trail__card-img"
                  src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=600&q=80"
                  data-fallback="https://picsum.photos/seed/trail-08/600/800"
                  alt=""
                  loading="eager"
                  draggable="false"
                />
              </div>
            </div>
          </div>
        </div>

        <div class="rotating-image-trail__title">
          <h1 class="rotating-image-trail__name">Abbos Nurgulshanov</h1>
          <p class="rotating-image-trail__label">Rotating Image Trail Effect</p>
        </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
/* ─────────────────────────────────────────────
   HTML / CSS / JS / GSAP
───────────────────────────────────────────── */

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

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

body {
  font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  background: #000;
  color: #fff;
  overflow: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

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

.app {
  width: 100%;
  height: 100%;
  height: 100dvh;
  background: #000;
  overflow: hidden;
}

/* ── Trail stage ───────────────────────────── */

.rotating-image-trail {
  position: relative;
  width: 100%;
  height: 100%;
  height: 100dvh;
  overflow: hidden;
  cursor: default;
  -webkit-user-select: none;
  user-select: none;
  touch-action: none;
}

/* Source pool — never visible; clones are appended to the stage */
.rotating-image-trail__collection {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  visibility: hidden;
}

.rotating-image-trail__list {
  display: flex;
  flex-wrap: wrap;
  gap: 1em;
}

.rotating-image-trail__item {
  z-index: 10;
  pointer-events: none;
  -webkit-user-select: none;
  user-select: none;
  will-change: transform;
}

/* Clones live as absolute children of the stage */
.rotating-image-trail > .rotating-image-trail__item {
  position: absolute;
  top: 0;
  left: 0;
  margin: 0;
}

.rotating-image-trail__card {
  position: relative;
  width: min(10vw, 140px);
  aspect-ratio: 3 / 4;
  overflow: hidden;
  border-radius: 2px;
  background: #111;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}

.rotating-image-trail__card-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Centered label (pointer-events none so trail still works over it) */
.rotating-image-trail__title {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: grid;
  place-content: center;
  place-items: center;
  gap: 1.15em;
  margin: 0;
  padding: 1.5rem;
  text-align: center;
  pointer-events: none;
  user-select: none;
}

.rotating-image-trail__name {
  margin: 0;
  font-size: clamp(2rem, 6.5vw, 5.5rem);
  font-weight: 500;
  letter-spacing: -0.03em;
  line-height: 1.05;
  color: #f2f2f2;
}

.rotating-image-trail__label {
  margin: 0;
  font-size: clamp(0.75rem, 1.35vw, 1rem);
  font-weight: 400;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  line-height: 1.3;
  color: rgba(242, 242, 242, 0.55);
}

/* ── Responsive card size ──────────────────── */

@media (max-width: 768px) {
  .rotating-image-trail__card {
    width: min(22vw, 110px);
  }

  .rotating-image-trail__name {
    font-size: clamp(1.6rem, 8vw, 2.75rem);
  }

  .rotating-image-trail__label {
    font-size: clamp(0.65rem, 2.5vw, 0.85rem);
    letter-spacing: 0.1em;
  }
}

@media (max-width: 480px) {
  .rotating-image-trail__card {
    width: min(28vw, 96px);
  }
}

/* Prefer reduced motion: still show trail, but CSS will not fight GSAP */
@media (prefers-reduced-motion: reduce) {
  .rotating-image-trail__item {
    will-change: auto;
  }
}
````

## Source — script.js
````javascript
/**
 * Rotating Image Trail
 * Cursor-driven image trail with scale + rotate in/out (GSAP).
 */

(() => {
  "use strict";

  if (typeof gsap === "undefined") {
    console.error("[Rotating Image Trail] GSAP failed to load from CDN.");
    return;
  }

  /* --------------------------------------------------------------------------
   * Config — tweak feel without touching logic
   * -------------------------------------------------------------------------- */

  const CONFIG = {
    /** Fraction of card width that must be traveled before the next spawn */
    stepFactor: 0.5,
    /** How long a card stays fully visible before exit starts (ms) */
    holdMs: 400,
    /** Entrance duration (seconds) */
    inDuration: 0.4,
    /** Exit duration (seconds) */
    outDuration: 0.8,
    
    easeIn: "power3.out",
    /** Exit ease — accelerates into the spin-out */
    easeOut: "power2.in",
    /** Start rotation (deg) when hidden */
    rotateInFrom: -20,
    /** End rotation (deg) when exiting */
    rotateOutTo: 180,
    /** Soft cap on live clones (prevents overload on fast moves) */
    maxLive: 24,
  };

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

  function init() {
    const area = document.querySelector("[data-trail-area]");
    if (!area) return;

    const collection = area.querySelector("[data-trail-collection]");
    if (!collection) return;

    const sources = Array.from(collection.querySelectorAll("[data-trail-item]"));
    if (!sources.length) return;

    bindImageFallbacks(area);
    initTrail(area, sources);
  }

  function bindImageFallbacks(root) {
    root.querySelectorAll("img[data-fallback]").forEach((img) => {
      let tried = false;
      img.addEventListener("error", () => {
        if (tried) return;
        tried = true;
        const fb = img.getAttribute("data-fallback");
        if (fb) img.src = fb;
      });
    });
  }

  /* --------------------------------------------------------------------------
   * Trail engine
   * -------------------------------------------------------------------------- */

  function initTrail(area, sources) {
    let index = 0;
    let lastX = null;
    let lastY = null;
    let stepDistance = measureStep(sources[0]);
    let liveCount = 0;
    let pointerActive = false;

    const reducedMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    function measureStep(item) {
      // Prefer a live clone width if one exists on stage
      const live = area.querySelector(".rotating-image-trail__item.is-clone .rotating-image-trail__card");
      if (live) {
        const lw = live.getBoundingClientRect().width;
        if (lw > 0) return lw * CONFIG.stepFactor;
      }

      // Probe: briefly mount an off-stage measure node (source pool is visibility:hidden / 0-size)
      const probe = item.cloneNode(true);
      probe.style.cssText =
        "position:absolute;left:-9999px;top:0;visibility:hidden;pointer-events:none;";
      area.appendChild(probe);
      const card = probe.querySelector(".rotating-image-trail__card") || probe;
      const w = card.getBoundingClientRect().width;
      probe.remove();

      if (w > 0) return w * CONFIG.stepFactor;

      // Viewport fallback matching CSS: min(10vw, 140px)
      return Math.min(window.innerWidth * 0.1, 140) * CONFIG.stepFactor;
    }

    function spawn(x, y) {
      if (liveCount >= CONFIG.maxLive) return;

      const original = sources[index];
      const clone = original.cloneNode(true);
      clone.removeAttribute("data-trail-item");
      clone.classList.add("is-clone");

      // Center clone on cursor via transform origin + left/top
      clone.style.left = `${x}px`;
      clone.style.top = `${y}px`;

      area.appendChild(clone);
      liveCount += 1;

      // Initial state — scaled down, slightly rotated
      gsap.set(clone, {
        xPercent: -50,
        yPercent: -50,
        scale: 0,
        rotation: CONFIG.rotateInFrom,
        force3D: true,
        transformOrigin: "50% 50%",
      });

      const hold = reducedMotion ? 120 : CONFIG.holdMs;
      const inDur = reducedMotion ? 0.15 : CONFIG.inDuration;
      const outDur = reducedMotion ? 0.25 : CONFIG.outDuration;

      const tl = gsap.timeline({
        onComplete: () => {
          clone.remove();
          liveCount = Math.max(0, liveCount - 1);
        },
      });

      // In
      tl.to(clone, {
        scale: 1,
        rotation: 0.001,
        duration: inDur,
        ease: CONFIG.easeIn,
      });

      // Hold, then spin-out + shrink
      tl.to(
        clone,
        {
          scale: 0,
          rotation: CONFIG.rotateOutTo,
          duration: outDur,
          ease: CONFIG.easeOut,
        },
        `+=${hold / 1000}`
      );

      index = (index + 1) % sources.length;
      lastX = x;
      lastY = y;
    }

    function localPoint(clientX, clientY) {
      const rect = area.getBoundingClientRect();
      return {
        x: clientX - rect.left,
        y: clientY - rect.top,
        w: rect.width,
        h: rect.height,
      };
    }

    function onMove(clientX, clientY) {
      const { x, y, w, h } = localPoint(clientX, clientY);

      if (x < 0 || y < 0 || x > w || y > h) {
        lastX = null;
        lastY = null;
        return;
      }

      if (lastX === null || lastY === null) {
        spawn(x, y);
        return;
      }

      const dx = x - lastX;
      const dy = y - lastY;
      const dist = Math.hypot(dx, dy);

      if (dist >= stepDistance) {
        spawn(x, y);
      }
    }

    // Mouse
    area.addEventListener(
      "mousemove",
      (e) => {
        onMove(e.clientX, e.clientY);
      },
      { passive: true }
    );

    area.addEventListener("mouseleave", () => {
      lastX = null;
      lastY = null;
      pointerActive = false;
    });

    // Touch / pen — same distance logic while dragging
    area.addEventListener(
      "pointerdown",
      (e) => {
        if (e.pointerType === "mouse") return;
        pointerActive = true;
        lastX = null;
        lastY = null;
        onMove(e.clientX, e.clientY);
      },
      { passive: true }
    );

    area.addEventListener(
      "pointermove",
      (e) => {
        if (e.pointerType === "mouse") return;
        if (!pointerActive) return;
        onMove(e.clientX, e.clientY);
      },
      { passive: true }
    );

    const endPointer = () => {
      pointerActive = false;
      lastX = null;
      lastY = null;
    };

    area.addEventListener("pointerup", endPointer, { passive: true });
    area.addEventListener("pointercancel", endPointer, { passive: true });

    // Re-measure step distance on resize (card size is vw-based)
    let resizeTimer = 0;
    window.addEventListener("resize", () => {
      clearTimeout(resizeTimer);
      resizeTimer = window.setTimeout(() => {
        stepDistance = measureStep(sources[0]);
        lastX = null;
        lastY = null;
      }, 100);
    });
  }

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