# Infinite Draggable Grid (Masonary)

> A masonry-style grid you can drag endlessly — exploratory layout for large image and product sets.

**Definition:** Infinite Draggable Grid (Masonary) 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-01
- Canonical page: https://html.bosconvertpro.com/collection/infinite-draggable-grid-masonary
- Full-page preview: https://html.bosconvertpro.com/collection/infinite-draggable-grid-masonary/full
- Live demo: https://html.bosconvertpro.com/demos/infinite-draggable-grid-masonary/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/infinite-draggable-grid-masonary/index.md
- License: https://html.bosconvertpro.com/terms

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

## FAQ
### What is Infinite Draggable Grid (Masonary)?

Infinite Draggable Grid (Masonary) is a sliders UI animation from collections.html. A masonry-style grid you can drag endlessly — exploratory layout for large image and product sets.

### What is Infinite Draggable Grid (Masonary) built with?

Infinite Draggable Grid (Masonary) 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 Infinite Draggable Grid (Masonary) in my project?

Open the animation page (https://html.bosconvertpro.com/collection/infinite-draggable-grid-masonary), 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/infinite-draggable-grid-masonary/index.html.

### Is Infinite Draggable Grid (Masonary) free to use?

Yes. The Infinite Draggable Grid (Masonary) 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>Infinite Draggable Grid — Masonry</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <main>
      <section
        class="infinite-grid"
        data-infinite-grid-init
        data-infinite-grid-status="loading"
        aria-label="Infinite draggable masonry grid"
      >
        <div class="infinite-grid__collection" data-infinite-grid-collection>
          <!-- Items are injected by script from a curated gallery -->
          <div class="infinite-grid__list" data-infinite-grid-list></div>
        </div>
      </section>
    </main>

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

## Source — styles.css
````css
/* ─────────────────────────────────────────────
   Infinite Draggable Grid — Masonry
───────────────────────────────────────────── */

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

html,
body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #000;
  color: #fff;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* ── Grid shell ─────────────────────────────── */

.infinite-grid {
  position: relative;
  width: 100%;
  height: 100svh;
  overflow: clip;
  touch-action: none;
  cursor: grab;
  opacity: 1;
  transition: opacity 0.55s cubic-bezier(0.22, 1, 0.36, 1);
  user-select: none;
  -webkit-user-select: none;
}

.infinite-grid[data-infinite-grid-status="loading"] {
  opacity: 0;
  pointer-events: none;
}

.infinite-grid[data-infinite-grid-status="dragging"] {
  cursor: grabbing;
}

/* ── Layers ─────────────────────────────────── */

.infinite-grid__collection {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  will-change: transform;
}

.infinite-grid__list {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

/* ── Item — slightly larger cards, roomy gaps ─ */

.infinite-grid__item {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  align-items: stretch;
  justify-content: stretch;
  width: 12.5em;
  padding: 2.15em;
  font-size: clamp(1.15em, 1.55vw, 2.15em);
  will-change: transform;
  backface-visibility: hidden;
}

.infinite-grid__item[data-ratio="1"] {
  aspect-ratio: 1 / 1;
}

.infinite-grid__item[data-ratio="2/3"] {
  aspect-ratio: 2 / 3;
}

.infinite-grid__item[data-ratio="3/4"] {
  aspect-ratio: 3 / 4;
}

.infinite-grid__item[data-ratio="4/5"] {
  aspect-ratio: 4 / 5;
}

.infinite-grid__item[data-ratio="5/6"] {
  aspect-ratio: 5 / 6;
}

.infinite-grid__item[data-ratio="4/3"] {
  aspect-ratio: 4 / 3;
}

.infinite-grid__item[data-ratio="5/4"] {
  aspect-ratio: 5 / 4;
}

/* ── Card ───────────────────────────────────── */

.infinite-grid__card {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 0.22em;
  overflow: hidden;
  will-change: transform;
  backface-visibility: hidden;
  background: #141414;
  box-shadow:
    0 0.08em 0.35em rgba(0, 0, 0, 0.45),
    0 0.35em 1em rgba(0, 0, 0, 0.28);
  border: 1px solid rgba(255, 255, 255, 0.04);
}

.infinite-grid__card-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
  pointer-events: none;
  -webkit-user-drag: none;
  opacity: 0;
  transition: opacity 0.35s ease;
  background: #1a1a1a;
}

.infinite-grid__card-img.is-loaded {
  opacity: 1;
}

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

@media (prefers-reduced-motion: reduce) {
  .infinite-grid,
  .infinite-grid__card-img {
    transition: none;
  }
}
````

## Source — script.js
````javascript
/**
 * Infinite Draggable Grid — Masonry
 *
 * Reliable images via seed-based Picsum (never 404) + multi-fallback.
 * Viewport-uniqueness packing + GSAP Observer drag.
 */

gsap.registerPlugin(Observer);

/* ── Gallery ───────────────────────────────────
   `seed` URLs always resolve (unlike fixed ids that can 404).
   Ratios create masonry variety.
─────────────────────────────────────────────── */
const GALLERY = [
  { seed: "masonry-01", ratio: "3/4", alt: "Portrait soft light" },
  { seed: "masonry-02", ratio: "1", alt: "Still life coffee" },
  { seed: "masonry-03", ratio: "4/5", alt: "Mountain landscape" },
  { seed: "masonry-04", ratio: "2/3", alt: "Fashion editorial" },
  { seed: "masonry-05", ratio: "5/4", alt: "Abstract light" },
  { seed: "masonry-06", ratio: "4/3", alt: "Interior architecture" },
  { seed: "masonry-07", ratio: "3/4", alt: "Studio portrait" },
  { seed: "masonry-08", ratio: "1", alt: "Fine dining" },
  { seed: "masonry-09", ratio: "5/6", alt: "Street fashion" },
  { seed: "masonry-10", ratio: "2/3", alt: "Close-up portrait" },
  { seed: "masonry-11", ratio: "4/5", alt: "Restaurant interior" },
  { seed: "masonry-12", ratio: "5/4", alt: "Foggy landscape" },
  { seed: "masonry-13", ratio: "3/4", alt: "Fashion portrait" },
  { seed: "masonry-14", ratio: "1", alt: "Food plate" },
  { seed: "masonry-15", ratio: "4/3", alt: "Modern office" },
  { seed: "masonry-16", ratio: "2/3", alt: "Studio fashion" },
  { seed: "masonry-17", ratio: "5/6", alt: "Natural portrait" },
  { seed: "masonry-18", ratio: "5/4", alt: "Night mountains" },
  { seed: "masonry-19", ratio: "1", alt: "Street style" },
  { seed: "masonry-20", ratio: "4/5", alt: "Kitchen still life" },
  { seed: "masonry-21", ratio: "3/4", alt: "Portrait smile" },
  { seed: "masonry-22", ratio: "4/3", alt: "Beach seascape" },
  { seed: "masonry-23", ratio: "2/3", alt: "City portrait" },
  { seed: "masonry-24", ratio: "1", alt: "Green landscape" },
  { seed: "masonry-25", ratio: "5/6", alt: "Architecture detail" },
  { seed: "masonry-26", ratio: "4/5", alt: "Coffee table" },
  { seed: "masonry-27", ratio: "3/4", alt: "Evening city" },
  { seed: "masonry-28", ratio: "5/4", alt: "Desert dunes" },
  { seed: "masonry-29", ratio: "2/3", alt: "Forest path" },
  { seed: "masonry-30", ratio: "1", alt: "Ocean waves" },
  { seed: "masonry-31", ratio: "4/3", alt: "Product still" },
  { seed: "masonry-32", ratio: "3/4", alt: "Editorial look" },
  { seed: "masonry-33", ratio: "5/6", alt: "Soft interior" },
  { seed: "masonry-34", ratio: "4/5", alt: "Travel moment" },
  { seed: "masonry-35", ratio: "2/3", alt: "Monochrome portrait" },
  { seed: "masonry-36", ratio: "1", alt: "Color study" },
];

/** Dimensions for a ratio at a given base width. */
function sizeForRatio(ratio, base = 520) {
  const parts = String(ratio).split("/");
  const rw = Number(parts[0]) || 1;
  const rh = Number(parts[1]) || 1;
  const w = base;
  const h = Math.max(1, Math.round((base * rh) / rw));
  return { w, h };
}

/**
 * Ordered URL candidates for one gallery entry.
 * Seed-based Picsum always returns a photo (no missing-id 404s).
 */
function galleryUrls(entry, base = 520) {
  const { w, h } = sizeForRatio(entry.ratio, base);
  const seed = encodeURIComponent(entry.seed);
  const seedAlt = encodeURIComponent(`${entry.seed}-alt`);
  return [
    // Primary — seed always resolves
    `https://picsum.photos/seed/${seed}/${w}/${h}`,
    // Same via Fastly CDN (skips redirect hop)
    `https://fastly.picsum.photos/seed/${seed}/${w}/${h}.jpg`,
    // Alternate seed if primary seed is blocked/rate-limited
    `https://picsum.photos/seed/${seedAlt}/${w}/${h}`,
    `https://fastly.picsum.photos/seed/${seedAlt}/${w}/${h}.jpg`,
  ];
}

/** Deterministic soft gradient if every network source fails. */
function fallbackDataUri(entry, index) {
  const hues = [12, 28, 200, 260, 320, 160, 40, 190];
  const h1 = hues[index % hues.length];
  const h2 = hues[(index + 3) % hues.length];
  const { w, h } = sizeForRatio(entry.ratio, 400);
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
    <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0%" stop-color="hsl(${h1} 18% 18%)"/>
      <stop offset="100%" stop-color="hsl(${h2} 16% 10%)"/>
    </linearGradient></defs>
    <rect width="100%" height="100%" fill="url(#g)"/>
  </svg>`;
  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}

/**
 * Load first working URL from a list. Resolves with { ok, src }.
 */
function loadFirstWorking(urls) {
  return new Promise((resolve) => {
    let i = 0;

    function tryNext() {
      if (i >= urls.length) {
        resolve({ ok: false, src: "" });
        return;
      }
      const src = urls[i++];
      const img = new Image();
      img.decoding = "async";
      img.referrerPolicy = "no-referrer";

      let settled = false;
      const done = (ok) => {
        if (settled) return;
        settled = true;
        img.onload = null;
        img.onerror = null;
        if (ok) resolve({ ok: true, src });
        else tryNext();
      };

      img.onload = () => done(img.naturalWidth > 0);
      img.onerror = () => done(false);
      img.src = src;

      // Cached hit (guarded so onload can't double-fire)
      if (img.complete) {
        done(img.naturalWidth > 0);
      }
    }

    tryNext();
  });
}

function initInfiniteCardsGrid() {
  const wrappers = document.querySelectorAll("[data-infinite-grid-init]");

  const wheelSpeed = 0.6;
  const dragSpeed = 1.2;
  const gridOverscan = 1;
  const startOffsetY = 0.18;
  const positionLerp = 0.055;
  const xToYInfluence = 0.18;
  const columnSpeedPattern = [1, 0.93, 1.04, 0.9, 1.02];
  const minCardScale = 0.58;
  const scaleLerp = 0.025;

  wrappers.forEach((wrapper) => {
    const collection = wrapper.querySelector("[data-infinite-grid-collection]");
    const sourceList = wrapper.querySelector("[data-infinite-grid-list]");
    if (!collection || !sourceList) return;

    /** @type {{ entry: object, src: string }[]} */
    let resolvedGallery = [];
    let originalItems = [];
    let observer = null;
    let cards = [];
    let cardElements = [];

    const timers = {};
    const size = {};
    const pos = {};
    const scale = { current: 1, target: 1 };

    function setStatus(status) {
      wrapper.setAttribute("data-infinite-grid-status", status);
    }

    function wrapValue(value, range) {
      if (!range) return value;
      return ((value % range) + range) % range;
    }

    // ── Resolve every image to a working src ──

    async function resolveGallery() {
      const settled = await Promise.all(
        GALLERY.map(async (entry, index) => {
          const result = await loadFirstWorking(galleryUrls(entry));
          const src = result.ok ? result.src : fallbackDataUri(entry, index);
          return { entry, src, ok: result.ok };
        })
      );

      // Prefer network images; keep gradient-only as last resort so count stays high
      const network = settled.filter((s) => s.ok);
      resolvedGallery = network.length >= 12 ? network : settled;

      return resolvedGallery;
    }

    // ── Build source DOM ──────────────────────

    function createSourceItems() {
      sourceList.innerHTML = "";
      const frag = document.createDocumentFragment();

      resolvedGallery.forEach(({ entry, src }) => {
        const item = document.createElement("div");
        item.className = "infinite-grid__item";
        item.dataset.infiniteGridItem = "";
        item.dataset.ratio = entry.ratio;

        const card = document.createElement("div");
        card.className = "infinite-grid__card";
        card.dataset.infiniteGridCard = "";

        const img = document.createElement("img");
        img.className = "infinite-grid__card-img";
        img.alt = entry.alt;
        img.decoding = "async";
        img.loading = "eager";
        img.draggable = false;
        img.referrerPolicy = "no-referrer";
        img.src = src;

        card.appendChild(img);
        item.appendChild(card);
        frag.appendChild(item);
      });

      sourceList.appendChild(frag);

      originalItems = Array.from(
        sourceList.querySelectorAll("[data-infinite-grid-item]")
      ).map((el) => el.cloneNode(true));
    }

    function bindImageFade(img) {
      const mark = () => img.classList.add("is-loaded");

      if (img.complete && img.naturalWidth > 0) {
        mark();
        return Promise.resolve();
      }

      return new Promise((resolve) => {
        const finish = () => {
          mark();
          resolve();
        };
        img.addEventListener("load", finish, { once: true });
        img.addEventListener(
          "error",
          () => {
            // Should not happen (src already validated) — still unblank the card
            img.src = fallbackDataUri(
              { ratio: "3/4", seed: "err" },
              Math.floor(Math.random() * 8)
            );
            finish();
          },
          { once: true }
        );
      });
    }

    function waitForDomImages() {
      return Promise.all(
        Array.from(sourceList.querySelectorAll("img")).map(bindImageFade)
      );
    }

    // ── Measure ───────────────────────────────

    function measureSources() {
      const probe = document.createElement("div");
      probe.style.cssText =
        "position:absolute;visibility:hidden;pointer-events:none;top:0;left:0;";
      wrapper.appendChild(probe);

      const measured = originalItems.map((item) => {
        const clone = item.cloneNode(true);
        probe.appendChild(clone);
        const rect = clone.getBoundingClientRect();
        probe.removeChild(clone);
        return { w: rect.width, h: rect.height };
      });

      probe.remove();
      return measured;
    }

    function createColumnSpeeds(columns) {
      return Array.from(
        { length: columns },
        (_, i) => columnSpeedPattern[i % columnSpeedPattern.length]
      );
    }

    // ── Viewport-uniqueness packer ─────────────

    function canPlaceUnique(srcIndex, col, y, h, placed, colW, viewW, viewH) {
      for (let i = 0; i < placed.length; i++) {
        const p = placed[i];
        if (p.srcIndex !== srcIndex) continue;
        const dx = Math.abs(p.col - col) * colW;
        const cy = y + h * 0.5;
        const py = p.y + p.h * 0.5;
        const dy = Math.abs(cy - py);
        if (dx < viewW && dy < viewH) return false;
      }
      return true;
    }

    function packColumns(measured, colCount, minHeight, viewW, viewH) {
      const colW = measured[0].w;
      const heights = Array(colCount).fill(0);
      const placements = [];
      const total = measured.length;
      const usage = Array(total).fill(0);
      let guard = 0;

      while (Math.min(...heights) < minHeight && guard < 1200) {
        const col = heights.indexOf(Math.min(...heights));
        const y = heights[col];

        let bestIndex = -1;
        let bestScore = Infinity;

        for (let i = 0; i < total; i++) {
          const h = measured[i].h;
          const unique = canPlaceUnique(
            i,
            col,
            y,
            h,
            placements,
            colW,
            viewW,
            viewH
          );
          const score =
            (unique ? 0 : 10_000) +
            usage[i] * 100 +
            ((i * 17 + col * 31 + placements.length * 13) % 7);

          if (score < bestScore) {
            bestScore = score;
            bestIndex = i;
          }
        }

        if (bestIndex < 0) bestIndex = placements.length % total;

        const h = measured[bestIndex].h;
        placements.push({
          srcIndex: bestIndex,
          col,
          x: col * colW,
          y,
          h,
        });

        heights[col] += h;
        usage[bestIndex]++;
        guard++;
      }

      const totalH = Math.max(...heights);
      return {
        placements,
        colW,
        totalW: colCount * colW,
        totalH,
        columnHeights: heights.map(() => totalH),
      };
    }

    // ── Build / rebuild ───────────────────────

    function buildGrid() {
      if (observer) observer.kill();
      clearTimeout(timers.resize);
      clearTimeout(timers.scroll);
      clearTimeout(timers.scale);
      gsap.ticker.remove(updateGrid);

      setStatus("loading");
      sourceList.innerHTML = "";

      if (!originalItems.length) return;

      const measured = measureSources();
      if (!measured[0]?.w || !measured[0]?.h) return;

      const colW = measured[0].w;
      const avgH =
        measured.reduce((sum, m) => sum + m.h, 0) / measured.length;

      const columns = Math.max(
        2,
        Math.ceil(wrapper.clientWidth / colW) + gridOverscan * 2
      );

      const viewW = wrapper.clientWidth;
      const viewH = wrapper.clientHeight;
      const minHeight = viewH + avgH * (gridOverscan * 2 + 2);

      const packed = packColumns(measured, columns, minHeight, viewW, viewH);
      const columnSpeeds = createColumnSpeeds(columns);
      const centerColumn = Math.floor(columns / 2);

      size.colW = packed.colW;
      size.totalW = packed.totalW;
      size.totalH = packed.totalH;
      size.avgH = avgH;

      cards = [];
      cardElements = [];

      gsap.set(collection, { x: 0, y: 0, force3D: true });
      collection.style.width = `${size.totalW}px`;
      collection.style.height = `${size.totalH}px`;

      const fragment = document.createDocumentFragment();

      packed.placements.forEach((p, i) => {
        const item = originalItems[p.srcIndex].cloneNode(true);
        const card = item.querySelector("[data-infinite-grid-card]");
        const img = item.querySelector("img");

        item.style.width = `${packed.colW}px`;
        item.style.height = `${p.h}px`;
        item.style.aspectRatio = "auto";

        if (img) {
          img.loading = "eager";
          img.decoding = "async";
          img.draggable = false;
          img.referrerPolicy = "no-referrer";
          // Re-apply resolved src (cache hit from preload)
          const src = img.getAttribute("src") || img.src;
          img.src = src;
        }

        cards.push({
          baseX: p.x,
          baseY: p.y,
          col: p.col,
          colH: packed.columnHeights[p.col],
          startY: (p.col - centerColumn) * avgH * startOffsetY,
          ySpeed: columnSpeeds[p.col],
          xSetter: gsap.quickSetter(item, "x", "px"),
          ySetter: gsap.quickSetter(item, "y", "px"),
        });

        if (card) cardElements.push(card);
        if (i >= originalItems.length) item.setAttribute("aria-hidden", "true");

        fragment.appendChild(item);
      });

      sourceList.appendChild(fragment);
      gsap.set(cardElements, { force3D: true });

      pos.startX =
        wrapper.clientWidth * 0.5 -
        centerColumn * size.colW -
        size.colW * 0.5;
      pos.startY = wrapper.clientHeight * 0.5 - size.totalH * 0.22;
      pos.x = pos.startX;
      pos.y = pos.startY;
      pos.targetX = pos.x;
      pos.targetY = pos.y;

      scale.current = 1;
      scale.target = 1;

      updateGrid();
      gsap.ticker.add(updateGrid);

      observer = Observer.create({
        target: wrapper,
        type: "wheel,touch,pointer",
        preventDefault: true,
        dragMinimum: 3,
        onPress() {
          setStatus("dragging");
        },
        onRelease() {
          setStatus("idle");
        },
        onStop() {
          setStatus("idle");
        },
        onChange: handleMovement,
      });

      waitForDomImages().then(() => {
        requestAnimationFrame(() => setStatus("idle"));
      });
    }

    // ── Frame update ──────────────────────────

    function updateGrid() {
      pos.x += (pos.targetX - pos.x) * positionLerp;
      pos.y += (pos.targetY - pos.y) * positionLerp;
      scale.current += (scale.target - scale.current) * scaleLerp;

      const offsetX = size.colW * gridOverscan;
      const offsetY = size.avgH * gridOverscan;
      const scrollY = pos.y - pos.startY;

      cards.forEach(
        ({ baseX, baseY, colH, startY, ySpeed, xSetter, ySetter }) => {
          xSetter(wrapValue(baseX + pos.x + offsetX, size.totalW) - offsetX);
          ySetter(
            wrapValue(
              baseY + pos.startY + startY + scrollY * ySpeed + offsetY,
              colH
            ) - offsetY
          );
        }
      );

      gsap.set(cardElements, { scale: scale.current });
    }

    // ── Input ─────────────────────────────────

    function handleMovement(self) {
      const isWheel = self.event.type === "wheel";
      const speed = isWheel ? wheelSpeed : dragSpeed;

      const deltaX = gsap.utils.clamp(-80, 80, self.deltaX * speed);
      const deltaY = gsap.utils.clamp(-80, 80, self.deltaY * speed);

      const moveX = isWheel ? -deltaX : deltaX;
      const moveY = isWheel ? -deltaY : deltaY;

      const strength = gsap.utils.clamp(
        0,
        1,
        Math.max(Math.abs(deltaX), Math.abs(deltaY)) / 80
      );

      if (isWheel) {
        setStatus("scrolling");
        clearTimeout(timers.scroll);
        timers.scroll = setTimeout(() => setStatus("idle"), 200);
      }

      scale.target = gsap.utils.interpolate(1, minCardScale, strength);
      clearTimeout(timers.scale);
      timers.scale = setTimeout(() => {
        scale.target = 1;
      }, 130);

      pos.targetX += moveX;
      pos.targetY += moveY + moveX * xToYInfluence;
    }

    function handleMouseLeave() {
      setStatus("idle");
      scale.target = 1;
      if (observer) {
        observer.disable();
        observer.enable();
      }
    }

    // ── Events ────────────────────────────────

    window.addEventListener("resize", () => {
      clearTimeout(timers.resize);
      timers.resize = setTimeout(buildGrid, 200);
    });

    document.documentElement.addEventListener("mouseleave", handleMouseLeave);

    // Boot: resolve every URL → build sources → pack grid
    setStatus("loading");
    resolveGallery()
      .then(() => {
        createSourceItems();
        buildGrid();
      })
      .catch(() => {
        // Absolute last resort — gradients only
        resolvedGallery = GALLERY.map((entry, index) => ({
          entry,
          src: fallbackDataUri(entry, index),
          ok: false,
        }));
        createSourceItems();
        buildGrid();
      });
  });
}

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