# 3D Perspective Tiles Infinite Loop

> Perspective tiles that loop in depth with drag snap, tilt, and title reveals — immersive product and mood galleries.

**Definition:** 3D Perspective Tiles Infinite Loop 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-06
- Canonical page: https://html.bosconvertpro.com/collection/3d-perspective-tiles-infinite-loop
- Full-page preview: https://html.bosconvertpro.com/collection/3d-perspective-tiles-infinite-loop/full
- Live demo: https://html.bosconvertpro.com/demos/3d-perspective-tiles-infinite-loop/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/3d-perspective-tiles-infinite-loop/index.md
- License: https://html.bosconvertpro.com/terms

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

## FAQ
### What is 3D Perspective Tiles Infinite Loop?

3D Perspective Tiles Infinite Loop is a sliders UI animation from collections.html. Perspective tiles that loop in depth with drag snap, tilt, and title reveals — immersive product and mood galleries.

### What is 3D Perspective Tiles Infinite Loop built with?

3D Perspective Tiles Infinite Loop 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 3D Perspective Tiles Infinite Loop in my project?

Open the animation page (https://html.bosconvertpro.com/collection/3d-perspective-tiles-infinite-loop), 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/3d-perspective-tiles-infinite-loop/index.html.

### Is 3D Perspective Tiles Infinite Loop free to use?

Yes. The 3D Perspective Tiles Infinite Loop 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>3D Perspective Tiles Infinite Loop</title>
    <link rel="preconnect" href="https://cdn.jsdelivr.net" />
    <link rel="preconnect" href="https://images.unsplash.com" />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <section class="demo-section">
      <div
        class="perspective-tiles"
        data-perspective-tiles-init
        data-perspective-tiles-pause-hover="false"
        data-perspective-tiles-tilt="true"
        data-perspective-tiles-direction="forward"
      >
        <div class="perspective-tiles__collection" data-perspective-tiles-collection>
          <div class="perspective-tiles__list" data-perspective-tiles-list>
            <div class="perspective-tiles__item" data-perspective-tiles-item data-perspective-tiles-item-status="active">
              <article class="demo-card">
                <img class="demo-card__image" src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1200&q=85" alt="Mountain landscape" loading="lazy" />
                <div class="demo-card__scrim" aria-hidden="true"></div>
                <h3 class="demo-card__title demo-card__title--beige">Rock</h3>
              </article>
            </div>

            <div class="perspective-tiles__item" data-perspective-tiles-item data-perspective-tiles-item-status="not-active">
              <article class="demo-card">
                <img class="demo-card__image" src="https://images.unsplash.com/photo-1616594039964-ae9021a400a0?auto=format&fit=crop&w=1200&q=85" alt="Minimal bedroom interior" loading="lazy" />
                <div class="demo-card__scrim" aria-hidden="true"></div>
                <h3 class="demo-card__title demo-card__title--sage">Relax</h3>
              </article>
            </div>

            <div class="perspective-tiles__item" data-perspective-tiles-item data-perspective-tiles-item-status="not-active">
              <article class="demo-card">
                <img class="demo-card__image" src="https://images.unsplash.com/photo-1416879595882-3373a0480b5b?auto=format&fit=crop&w=1200&q=85" alt="Green plant leaves" loading="lazy" />
                <div class="demo-card__scrim" aria-hidden="true"></div>
                <h3 class="demo-card__title demo-card__title--sage">Grow</h3>
              </article>
            </div>

            <div class="perspective-tiles__item" data-perspective-tiles-item data-perspective-tiles-item-status="not-active">
              <article class="demo-card">
                <img class="demo-card__image" src="https://images.unsplash.com/photo-1502680390469-be75c86b636f?auto=format&fit=crop&w=1200&q=85" alt="Surfer on the ocean" loading="lazy" />
                <div class="demo-card__scrim" aria-hidden="true"></div>
                <h3 class="demo-card__title demo-card__title--sky">Surf</h3>
              </article>
            </div>

            <div class="perspective-tiles__item" data-perspective-tiles-item data-perspective-tiles-item-status="not-active">
              <article class="demo-card">
                <img class="demo-card__image" src="https://images.unsplash.com/photo-1505142468610-359e7d316be0?auto=format&fit=crop&w=1200&q=85" alt="Ocean waves" loading="lazy" />
                <div class="demo-card__scrim" aria-hidden="true"></div>
                <h3 class="demo-card__title demo-card__title--sky">Drift</h3>
              </article>
            </div>

            <div class="perspective-tiles__item" data-perspective-tiles-item data-perspective-tiles-item-status="not-active">
              <article class="demo-card">
                <img class="demo-card__image" src="https://images.unsplash.com/photo-1522335789203-aabd1fc54bc9?auto=format&fit=crop&w=1200&q=85" alt="Soft skin and beauty detail" loading="lazy" />
                <div class="demo-card__scrim" aria-hidden="true"></div>
                <h3 class="demo-card__title demo-card__title--beige">Skin</h3>
              </article>
            </div>
          </div>
        </div>
      </div>
    </section>

    <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/ScrollTrigger.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 {
  --bg: #f6f1ea;
  --bg-soft: #fffaf5;
  --bg-mid: #f0e8df;
  --bg-deep: #ebe3d8;
  --ink: #2a241f;
  --card-w: clamp(12em, 20vw, 26em);
  --radius: 1.15em;
}

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

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

body {
  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--ink);
  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;
}

.demo-section {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100svh;
  background:
    radial-gradient(ellipse 80% 60% at 50% 40%, var(--bg-soft) 0%, transparent 70%),
    linear-gradient(165deg, var(--bg-soft) 0%, var(--bg-mid) 48%, var(--bg-deep) 100%);
}

.perspective-tiles {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100dvh;
  overflow: clip;
  cursor: grab;
  touch-action: none;
  user-select: none;
}

.perspective-tiles.is-dragging {
  cursor: grabbing;
}

.perspective-tiles__collection {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}

.perspective-tiles__list {
  display: grid;
  place-items: center;
}

.perspective-tiles__item {
  grid-area: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: center;
  width: max-content;
  height: max-content;
  will-change: transform, opacity;
}

.demo-card {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--card-w);
  aspect-ratio: 4 / 3;
  overflow: hidden;
  border-radius: var(--radius);
  box-shadow:
    0 1px 0 rgba(255, 255, 255, 0.55) inset,
    0 18px 40px rgba(42, 36, 31, 0.12),
    0 4px 12px rgba(42, 36, 31, 0.06);
}

.demo-card__image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
  will-change: filter;
}

.demo-card__scrim {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background:
    radial-gradient(ellipse 70% 55% at 50% 52%, rgba(20, 16, 12, 0.42) 0%, rgba(20, 16, 12, 0.08) 55%, transparent 72%),
    linear-gradient(180deg, rgba(20, 16, 12, 0.12) 0%, transparent 35%, transparent 65%, rgba(20, 16, 12, 0.18) 100%);
  pointer-events: none;
  will-change: filter;
}

.demo-card__title {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0;
  font-size: calc(var(--card-w) / 5.5);
  font-weight: 800;
  letter-spacing: -0.03em;
  line-height: 1;
  text-transform: uppercase;
  color: #fff;
  opacity: 0;
  visibility: hidden;
  perspective: 700px;
  transform-style: preserve-3d;
  text-shadow:
    0 1px 1px rgba(0, 0, 0, 0.35),
    0 2px 10px rgba(0, 0, 0, 0.35),
    0 0 24px rgba(0, 0, 0, 0.2);
  will-change: transform, opacity, filter;
  pointer-events: none;
}

.demo-card__char {
  display: inline-block;
  will-change: transform, opacity, filter;
}

.demo-card__title--beige {
  color: #fff4ea;
}

.demo-card__title--sky {
  color: #eef6fc;
}

.demo-card__title--sage {
  color: #f3f6e8;
}
````

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

CustomEase.create("osmo", "M0,0 C0.625,0.05 0,1 1,1");
CustomEase.create("reveal", "M0,0 C0.22,1 0.36,1 1,1");

function init3dPerspectiveTiles() {
  document.querySelectorAll("[data-perspective-tiles-init]").forEach((container) => {
    const collection = container.querySelector("[data-perspective-tiles-collection]");
    const list = container.querySelector("[data-perspective-tiles-list]");
    const tiles = [...container.querySelectorAll("[data-perspective-tiles-item]")];
    const titles = tiles.map((tile) => tile.querySelector(".demo-card__title"));
    const tileCount = tiles.length;

    if (!collection || !list || tileCount < 2) return;

    const config = {
      gapPercent: 0.45,
      perspectiveMultiplier: 4,
      blurMultiplier: 0.005,
      minOpacity: 1,
      minDarkness: 0.82,
      maxTiltX: 8,
      maxTiltY: 8,
      tiltMoveDuration: 0.6,
      moveDuration: 2.5,
      pauseDuration: 0,
      dragSensitivity: 1,
      dragVelocityBoost: 0.0018,
      snapDuration: 0.85,
      titleInDuration: 1.05,
      titleOutDuration: 0.55,
      titleStagger: 0.035,
      titleOffsetX: 28,
      titleOffsetY: 18,
    };

    const tiltEnabled = container.getAttribute("data-perspective-tiles-tilt") === "true";
    const pauseOnHover = container.getAttribute("data-perspective-tiles-pause-hover") === "true";
    const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
    const fullCircle = Math.PI * 2;
    const titleChars = titles.map((title) => splitTitle(title));
    const progressState = { value: 0 };

    let direction = readDirection(container);
    let radius = 0;
    let maxBlur = 0;
    let isInView = false;
    let isHoveringActive = false;
    let isDragging = false;
    let stepTimeline;
    let snapTween;
    let delayedCall;
    let activeTileIndex = -1;
    let titleTimeline;
    let dragStartX = 0;
    let dragStartProgress = 0;
    let lastPointerX = 0;
    let lastPointerTime = 0;
    let dragVelocity = 0;

    gsap.set(collection, {
      transformStyle: "preserve-3d",
      transformOrigin: "50% 50%",
    });

    gsap.set(list, {
      transformStyle: "preserve-3d",
    });

    titles.forEach((title, index) => {
      gsap.set(title, { autoAlpha: 0 });
      gsap.set(titleChars[index], {
        autoAlpha: 0,
        y: config.titleOffsetY,
        x: -direction * config.titleOffsetX * 0.35,
        filter: "blur(10px)",
        scale: 0.92,
      });
    });

    function splitTitle(title) {
      if (!title) return [];

      const text = title.textContent.trim();
      title.textContent = "";

      return [...text].map((char) => {
        const span = document.createElement("span");
        span.className = "demo-card__char";
        span.textContent = char === " " ? "\u00A0" : char;
        title.appendChild(span);
        return span;
      });
    }

    function readDirection(root) {
      const value = (root.getAttribute("data-perspective-tiles-direction") || "forward").toLowerCase();
      const flipped = root.getAttribute("data-perspective-tiles-flipped") === "true";
      if (value === "reverse" || value === "-1" || flipped) return -1;
      return 1;
    }

    function normalizeIndex(value) {
      return ((value % tileCount) + tileCount) % tileCount;
    }

    function updateMeasurements() {
      const tileWidth = tiles[0].offsetWidth;
      radius = (tileWidth * (1 + config.gapPercent)) / (2 * Math.tan(Math.PI / tileCount));
      maxBlur = tileWidth * config.blurMultiplier;

      gsap.set(collection, {
        transformPerspective: radius * config.perspectiveMultiplier,
      });
    }

    function getActiveIndex() {
      return normalizeIndex(Math.round(progressState.value));
    }

    function setDirection(nextDirection) {
      const next = nextDirection < 0 ? -1 : 1;
      if (next === direction) return;
      direction = next;
      container.setAttribute(
        "data-perspective-tiles-direction",
        direction > 0 ? "forward" : "reverse"
      );
    }

    function revealTitle(index, animate = true) {
      const title = titles[index];
      const chars = titleChars[index];
      if (!title || !chars.length) return;

      titleTimeline?.kill();

      if (!animate) {
        gsap.set(title, { autoAlpha: 1 });
        gsap.set(chars, {
          autoAlpha: 1,
          x: 0,
          y: 0,
          scale: 1,
          filter: "blur(0px)",
          rotateX: 0,
        });
        return;
      }

      titleTimeline = gsap.timeline();
      titleTimeline.set(title, { autoAlpha: 1 }, 0);
      titleTimeline.fromTo(
        chars,
        {
          autoAlpha: 0,
          x: -direction * config.titleOffsetX,
          y: config.titleOffsetY,
          scale: 0.9,
          filter: "blur(12px)",
          rotateX: 28,
        },
        {
          autoAlpha: 1,
          x: 0,
          y: 0,
          scale: 1,
          filter: "blur(0px)",
          rotateX: 0,
          duration: config.titleInDuration,
          stagger: {
            each: config.titleStagger,
            from: direction > 0 ? "start" : "end",
          },
          ease: "osmo",
        },
        0.04
      );
    }

    function hideTitle(index, animate = true) {
      const title = titles[index];
      const chars = titleChars[index];
      if (!title || !chars.length) return;

      if (!animate) {
        gsap.set(title, { autoAlpha: 0 });
        gsap.set(chars, {
          autoAlpha: 0,
          x: direction * config.titleOffsetX * 0.4,
          y: -config.titleOffsetY * 0.5,
          scale: 0.96,
          filter: "blur(8px)",
        });
        return;
      }

      gsap.to(chars, {
        autoAlpha: 0,
        x: direction * config.titleOffsetX * 0.55,
        y: -config.titleOffsetY * 0.65,
        scale: 0.94,
        filter: "blur(10px)",
        duration: config.titleOutDuration,
        stagger: {
          each: config.titleStagger * 0.65,
          from: direction > 0 ? "end" : "start",
        },
        ease: "power2.inOut",
        onComplete: () => {
          gsap.set(title, { autoAlpha: 0 });
        },
      });
    }

    function updateTileStatus(animateTitles = true) {
      const nextActive = getActiveIndex();
      if (nextActive === activeTileIndex) return;

      const previousActive = activeTileIndex;
      activeTileIndex = nextActive;

      tiles.forEach((tile, index) => {
        tile.setAttribute(
          "data-perspective-tiles-item-status",
          index === activeTileIndex ? "active" : "not-active"
        );
      });

      if (previousActive >= 0) hideTitle(previousActive, animateTitles);
      revealTitle(activeTileIndex, animateTitles);
    }

    function renderPerspectiveTiles(animateTitles = true) {
      updateTileStatus(animateTitles);

      tiles.forEach((tile, index) => {
        const angle = ((index - progressState.value) / tileCount) * fullCircle;
        const depth = (Math.cos(angle) + 1) / 2;
        const depthCurve = depth * depth * (3 - 2 * depth);
        const media = tile.querySelectorAll(".demo-card__image, .demo-card__scrim");

        gsap.set(tile, {
          x: Math.sin(angle) * radius,
          z: Math.cos(angle) * radius,
          rotateY: (angle * 180) / Math.PI,
          opacity: gsap.utils.interpolate(config.minOpacity, 1, depthCurve),
          zIndex: Math.round(depth * 1000),
        });

        gsap.set(media, {
          filter: `blur(${gsap.utils.interpolate(maxBlur, 0, depthCurve)}px) brightness(${gsap.utils.interpolate(config.minDarkness, 1, depthCurve)})`,
        });
      });
    }

    function stopMotion() {
      delayedCall?.kill();
      stepTimeline?.kill();
      snapTween?.kill();
      stepTimeline = null;
      snapTween = null;
      delayedCall = null;
    }

    function waitForNextTile() {
      delayedCall?.kill();
      if (!isInView || isDragging || (pauseOnHover && isHoveringActive)) return;
      delayedCall = gsap.delayedCall(config.pauseDuration, goToNextTile);
    }

    function goToNextTile() {
      if (!isInView || isDragging || (pauseOnHover && isHoveringActive)) return;

      const target = progressState.value + direction;

      stepTimeline = gsap.to(progressState, {
        value: target,
        duration: config.moveDuration,
        ease: "osmo",
        onUpdate: () => renderPerspectiveTiles(true),
        onComplete: waitForNextTile,
      });
    }

    function snapAndResume() {
      const velocityBoost = gsap.utils.clamp(-1.25, 1.25, dragVelocity * config.dragVelocityBoost);
      let target = progressState.value + velocityBoost;

      if (Math.abs(dragVelocity) > 0.35) {
        setDirection(dragVelocity < 0 ? 1 : -1);
        target = direction > 0 ? Math.ceil(progressState.value - 0.001) : Math.floor(progressState.value + 0.001);
      } else {
        target = Math.round(progressState.value);
      }

      const distance = Math.abs(target - progressState.value);
      const duration = gsap.utils.clamp(0.45, 1.1, config.snapDuration * (0.55 + distance));

      snapTween = gsap.to(progressState, {
        value: target,
        duration,
        ease: "osmo",
        onUpdate: () => renderPerspectiveTiles(true),
        onComplete: () => {
          progressState.value = target;
          renderPerspectiveTiles(true);
          waitForNextTile();
        },
      });
    }

    function bindDrag() {
      container.addEventListener("pointerdown", (event) => {
        if (event.button !== undefined && event.button !== 0) return;

        isDragging = true;
        container.classList.add("is-dragging");
        container.setPointerCapture?.(event.pointerId);

        stopMotion();
        dragStartX = event.clientX;
        dragStartProgress = progressState.value;
        lastPointerX = event.clientX;
        lastPointerTime = performance.now();
        dragVelocity = 0;
      });

      container.addEventListener("pointermove", (event) => {
        if (!isDragging) return;

        const now = performance.now();
        const deltaTime = Math.max(now - lastPointerTime, 1);
        const deltaX = event.clientX - lastPointerX;
        dragVelocity = (deltaX / deltaTime) * 16.67;
        lastPointerX = event.clientX;
        lastPointerTime = now;

        const dragDistance = event.clientX - dragStartX;
        const tileWidth = tiles[0].offsetWidth || 1;
        progressState.value =
          dragStartProgress - (dragDistance / tileWidth) * config.dragSensitivity;

        if (Math.abs(dragDistance) > 8) {
          setDirection(dragDistance < 0 ? 1 : -1);
        }

        renderPerspectiveTiles(true);
      });

      const endDrag = (event) => {
        if (!isDragging) return;
        isDragging = false;
        container.classList.remove("is-dragging");

        if (event?.pointerId !== undefined) {
          try {
            container.releasePointerCapture(event.pointerId);
          } catch (_) {}
        }

        snapAndResume();
      };

      container.addEventListener("pointerup", endDrag);
      container.addEventListener("pointercancel", endDrag);
      container.addEventListener("lostpointercapture", () => {
        if (!isDragging) return;
        isDragging = false;
        container.classList.remove("is-dragging");
        snapAndResume();
      });
    }

    function bindPauseOnHover() {
      if (!pauseOnHover || !canHover) return;

      tiles.forEach((tile) => {
        tile.addEventListener("pointerenter", () => {
          if (isDragging) return;
          if (tile.getAttribute("data-perspective-tiles-item-status") !== "active") return;
          isHoveringActive = true;
          delayedCall?.kill();
        });

        tile.addEventListener("pointerleave", () => {
          if (!isHoveringActive) return;
          isHoveringActive = false;
          if (!isDragging && !stepTimeline && !snapTween) waitForNextTile();
        });
      });
    }

    function bindTilt() {
      if (!tiltEnabled || !canHover) return;

      const tiltX = gsap.quickTo(collection, "rotationX", {
        duration: config.tiltMoveDuration,
        ease: "power3.out",
      });
      const tiltY = gsap.quickTo(collection, "rotationY", {
        duration: config.tiltMoveDuration,
        ease: "power3.out",
      });

      container.addEventListener("pointermove", (event) => {
        if (isDragging) return;
        const rect = container.getBoundingClientRect();
        const x = (event.clientX - rect.left) / rect.width - 0.5;
        const y = (event.clientY - rect.top) / rect.height - 0.5;
        tiltX(-y * config.maxTiltY * 2);
        tiltY(x * config.maxTiltX * 2);
      });
    }

    updateMeasurements();
    activeTileIndex = getActiveIndex();
    tiles.forEach((tile, index) => {
      tile.setAttribute(
        "data-perspective-tiles-item-status",
        index === activeTileIndex ? "active" : "not-active"
      );
    });
    renderPerspectiveTiles(false);
    revealTitle(activeTileIndex, true);

    bindDrag();
    bindPauseOnHover();
    bindTilt();

    new ResizeObserver(() => {
      updateMeasurements();
      renderPerspectiveTiles(false);
    }).observe(container);

    ScrollTrigger.create({
      trigger: container,
      start: "top bottom",
      end: "bottom top",
      onToggle: (self) => {
        isInView = self.isActive;
        if (!isInView) {
          stopMotion();
          return;
        }
        if (!isDragging && !stepTimeline && !snapTween) waitForNextTile();
      },
    });
  });
}

document.addEventListener("DOMContentLoaded", init3dPerspectiveTiles);
````
