# Infinite Dome Grid

> An infinite curved dome grid gallery with draggable canvas, flashlight cursor lens, and seamless lightbox detail reveals.

**Definition:** Infinite Dome Grid 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-09-02
- Canonical page: https://html.bosconvertpro.com/collection/infinite-dome-grid
- Full-page preview: https://html.bosconvertpro.com/collection/infinite-dome-grid/full
- Live demo: https://html.bosconvertpro.com/demos/infinite-dome-grid/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/infinite-dome-grid/index.md
- License: https://html.bosconvertpro.com/terms

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

## FAQ
### What is Infinite Dome Grid?

Infinite Dome Grid is a sliders UI animation from collections.html. An infinite curved dome grid gallery with draggable canvas, flashlight cursor lens, and seamless lightbox detail reveals.

### What is Infinite Dome Grid built with?

Infinite Dome Grid 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 Dome Grid in my project?

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

### Is Infinite Dome Grid free to use?

Yes. The Infinite Dome Grid 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, maximum-scale=1">
  <title>Infinite Dome Grid</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Inter:wght@400;500&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="styles.css">
</head>
<body>

  <section data-dome-grid class="dome-grid">
    <div class="dome-grid__collection">
      <div data-dome-grid-cards class="dome-grid__cards">

        <div data-dome-grid-card class="dome-grid__card">
          <div class="dome-grid__card-panel"></div>
          <div class="dome-grid__card-image-wrap">
            <img src="https://images.unsplash.com/photo-1760840840103-193cf05daedf?q=80&w=1200&auto=format&fit=crop" loading="eager" data-dome-grid-image draggable="false" alt="" class="dome-grid__card-image">
            <div class="dome-grid__card-caption">
              <p class="dome-grid__card-title">Azure</p>
              <p class="dome-grid__card-meta">Identity, 2026</p>
            </div>
            <div class="dome-grid__card-dim"></div>
          </div>
          <div data-dome-grid-detail-content class="dome-grid__detail-content">
            <h2 class="dome-grid__detail-title">Azure</h2>
            <p class="dome-grid__detail-text is--faded">An identity for a fashion label, built on one blue and the shapes a body makes against it in stark light.</p>
          </div>
        </div>

        <div data-dome-grid-card class="dome-grid__card">
          <div class="dome-grid__card-panel"></div>
          <div class="dome-grid__card-image-wrap">
            <img src="https://images.unsplash.com/photo-1761797033350-f172ed270d99?q=80&w=1200&auto=format&fit=crop" loading="eager" data-dome-grid-image draggable="false" alt="" class="dome-grid__card-image">
            <div class="dome-grid__card-caption">
              <p class="dome-grid__card-title">Oak</p>
              <p class="dome-grid__card-meta">Nature, 2025</p>
            </div>
            <div class="dome-grid__card-dim"></div>
          </div>
          <div data-dome-grid-detail-content class="dome-grid__detail-content">
            <h2 class="dome-grid__detail-title">Oak</h2>
            <p class="dome-grid__detail-text is--faded">A study of a sunlit oak canopy at golden hour, where light breaks the leaves into a thousand greens.</p>
          </div>
        </div>

        <div data-dome-grid-card class="dome-grid__card">
          <div class="dome-grid__card-panel"></div>
          <div class="dome-grid__card-image-wrap">
            <img src="https://images.unsplash.com/photo-1761775247546-89950362a39b?q=80&w=1200&auto=format&fit=crop" loading="eager" data-dome-grid-image draggable="false" alt="" class="dome-grid__card-image">
            <div class="dome-grid__card-caption">
              <p class="dome-grid__card-title">Pump</p>
              <p class="dome-grid__card-meta">Product, 2025</p>
            </div>
            <div class="dome-grid__card-dim"></div>
          </div>
          <div data-dome-grid-detail-content class="dome-grid__detail-content">
            <h2 class="dome-grid__detail-title">Pump</h2>
            <p class="dome-grid__detail-text is--faded">A single pump bottle against an orange gradient, photographed like a monument.</p>
          </div>
        </div>

        <div data-dome-grid-card class="dome-grid__card">
          <div class="dome-grid__card-panel"></div>
          <div class="dome-grid__card-image-wrap">
            <img src="https://images.unsplash.com/photo-1716167738533-b42e197ba85f?q=80&w=1200&auto=format&fit=crop" loading="eager" data-dome-grid-image draggable="false" alt="" class="dome-grid__card-image">
            <div class="dome-grid__card-caption">
              <p class="dome-grid__card-title">Stallion</p>
              <p class="dome-grid__card-meta">Portraiture, 2026</p>
            </div>
            <div class="dome-grid__card-dim"></div>
          </div>
          <div data-dome-grid-detail-content class="dome-grid__detail-content">
            <h2 class="dome-grid__detail-title">Stallion</h2>
            <p class="dome-grid__detail-text is--faded">An elegant black horse against a rich red ground — power, stillness, and the weight of a single subject.</p>
          </div>
        </div>

        <div data-dome-grid-card class="dome-grid__card">
          <div class="dome-grid__card-panel"></div>
          <div class="dome-grid__card-image-wrap">
            <img src="https://images.unsplash.com/photo-1761111623614-7f92bffa6e98?q=80&w=1200&auto=format&fit=crop" loading="eager" data-dome-grid-image draggable="false" alt="" class="dome-grid__card-image">
            <div class="dome-grid__card-caption">
              <p class="dome-grid__card-title">Stem</p>
              <p class="dome-grid__card-meta">Nature, 2025</p>
            </div>
            <div class="dome-grid__card-dim"></div>
          </div>
          <div data-dome-grid-detail-content class="dome-grid__detail-content">
            <h2 class="dome-grid__detail-title">Stem</h2>
            <p class="dome-grid__detail-text is--faded">A delicate green stem with buds, photographed like architecture.</p>
          </div>
        </div>

        <div data-dome-grid-card class="dome-grid__card">
          <div class="dome-grid__card-panel"></div>
          <div class="dome-grid__card-image-wrap">
            <img src="https://images.unsplash.com/photo-1669224111969-6d75135cfed5?q=80&w=1200&auto=format&fit=crop" loading="eager" data-dome-grid-image draggable="false" alt="" class="dome-grid__card-image">
            <div class="dome-grid__card-caption">
              <p class="dome-grid__card-title">Drive</p>
              <p class="dome-grid__card-meta">Editorial, 2026</p>
            </div>
            <div class="dome-grid__card-dim"></div>
          </div>
          <div data-dome-grid-detail-content class="dome-grid__detail-content">
            <h2 class="dome-grid__detail-title">Drive</h2>
            <p class="dome-grid__detail-text is--faded">An elegant businesswoman driving a vintage convertible — cinema in a single frame.</p>
          </div>
        </div>

        <div data-dome-grid-card class="dome-grid__card">
          <div class="dome-grid__card-panel"></div>
          <div class="dome-grid__card-image-wrap">
            <img src="https://images.unsplash.com/photo-1763477950478-0b3f7d6a2a7d?q=80&w=1200&auto=format&fit=crop" loading="eager" data-dome-grid-image draggable="false" alt="" class="dome-grid__card-image">
            <div class="dome-grid__card-caption">
              <p class="dome-grid__card-title">Prism</p>
              <p class="dome-grid__card-meta">Product, 2025</p>
            </div>
            <div class="dome-grid__card-dim"></div>
          </div>
          <div data-dome-grid-detail-content class="dome-grid__detail-content">
            <h2 class="dome-grid__detail-title">Prism</h2>
            <p class="dome-grid__detail-text is--faded">A sunlit glass stem base catching prism refractions on linen.</p>
          </div>
        </div>

        <div data-dome-grid-card class="dome-grid__card">
          <div class="dome-grid__card-panel"></div>
          <div class="dome-grid__card-image-wrap">
            <img src="https://images.unsplash.com/photo-1643305817399-2e622f604204?q=80&w=1200&auto=format&fit=crop" loading="eager" data-dome-grid-image draggable="false" alt="" class="dome-grid__card-image">
            <div class="dome-grid__card-caption">
              <p class="dome-grid__card-title">Tropic</p>
              <p class="dome-grid__card-meta">Fashion, 2026</p>
            </div>
            <div class="dome-grid__card-dim"></div>
          </div>
          <div data-dome-grid-detail-content class="dome-grid__detail-content">
            <h2 class="dome-grid__detail-title">Tropic</h2>
            <p class="dome-grid__detail-text is--faded">A moody botanical portrait framed by tropical leaves.</p>
          </div>
        </div>

        <div data-dome-grid-card class="dome-grid__card">
          <div class="dome-grid__card-panel"></div>
          <div class="dome-grid__card-image-wrap">
            <img src="https://images.unsplash.com/photo-1767128890439-1af9ca2ff1ac?q=80&w=1200&auto=format&fit=crop" loading="eager" data-dome-grid-image draggable="false" alt="" class="dome-grid__card-image">
            <div class="dome-grid__card-caption">
              <p class="dome-grid__card-title">Padel</p>
              <p class="dome-grid__card-meta">Sport, 2026</p>
            </div>
            <div class="dome-grid__card-dim"></div>
          </div>
          <div data-dome-grid-detail-content class="dome-grid__detail-content">
            <h2 class="dome-grid__detail-title">Padel</h2>
            <p class="dome-grid__detail-text is--faded">A minimal black padel racket, close up in the studio.</p>
          </div>
        </div>

        <div data-dome-grid-card class="dome-grid__card">
          <div class="dome-grid__card-panel"></div>
          <div class="dome-grid__card-image-wrap">
            <img src="https://images.unsplash.com/photo-1761977317806-594b684614cd?q=80&w=1200&auto=format&fit=crop" loading="eager" data-dome-grid-image draggable="false" alt="" class="dome-grid__card-image">
            <div class="dome-grid__card-caption">
              <p class="dome-grid__card-title">Blur</p>
              <p class="dome-grid__card-meta">Portraiture, 2026</p>
            </div>
            <div class="dome-grid__card-dim"></div>
          </div>
          <div data-dome-grid-detail-content class="dome-grid__detail-content">
            <h2 class="dome-grid__detail-title">Blur</h2>
            <p class="dome-grid__detail-text is--faded">An ethereal motion portrait with a soft color palette.</p>
          </div>
        </div>

        <div data-dome-grid-card class="dome-grid__card">
          <div class="dome-grid__card-panel"></div>
          <div class="dome-grid__card-image-wrap">
            <img src="https://images.unsplash.com/photo-1708035515944-695022f42091?q=80&w=1200&auto=format&fit=crop" loading="eager" data-dome-grid-image draggable="false" alt="" class="dome-grid__card-image">
            <div class="dome-grid__card-caption">
              <p class="dome-grid__card-title">Riviera</p>
              <p class="dome-grid__card-meta">Travel, 2025</p>
            </div>
            <div class="dome-grid__card-dim"></div>
          </div>
          <div data-dome-grid-detail-content class="dome-grid__detail-content">
            <h2 class="dome-grid__detail-title">Riviera</h2>
            <p class="dome-grid__detail-text is--faded">A sunlit seascape framed by a stone balustrade.</p>
          </div>
        </div>

      </div>
    </div>
    <div aria-hidden="true" data-dome-grid-flashlight class="dome-grid__flashlight"></div>
    <aside data-dome-grid-detail class="dome-grid__detail">
      <button type="button" data-dome-grid-close class="dome-grid__detail-close" aria-label="Close">
        <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
          <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"></path>
        </svg>
      </button>
      <div data-dome-grid-detail-scroll class="dome-grid__detail-scroll"></div>
    </aside>
    <div aria-hidden="true" data-dome-grid-cover class="dome-grid__cover"></div>
  </section>

  <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
*, *::before, *::after {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  background-color: #0f0f0f;
  overflow: hidden;
}

.dome-grid {
  --panel-gap: -2em;
  cursor: grab;
  touch-action: none;
  overscroll-behavior: none;
  -webkit-user-select: none;
  user-select: none;
  background-color: #0f0f0f;
  width: 100%;
  height: 100svh;
  position: relative;
  overflow: hidden;
}

.dome-grid[data-dragging] {
  cursor: grabbing;
}

.dome-grid__cards {
  grid-template-columns: repeat(5, 1fr);
  gap: 2em;
  width: 100%;
  display: grid;
}

.dome-grid__card {
  --slide: 0;
  --dim: 0;
  aspect-ratio: 1;
  transform-origin: 0 0;
  will-change: transform;
  position: relative;
}

.dome-grid__card[data-lifted] {
  z-index: 10;
}

.dome-grid__card[data-active] .dome-grid__card-caption {
  opacity: 0;
}

.dome-grid__card .dome-grid__detail-content {
  display: none;
}

.dome-grid__card-panel {
  pointer-events: none;
  transform: translateX(calc((100% + var(--panel-gap)) * var(--slide)));
  background-color: #e8e8e6;
  border-radius: 1em;
  position: absolute;
  inset: 0%;
}

.dome-grid__card-image-wrap {
  background-color: #141414;
  border-radius: 1em;
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
}

.dome-grid__card-image {
  pointer-events: none;
  object-fit: cover;
  width: 100%;
  height: 100%;
  position: absolute;
  inset: 0%;
  transform: scale(1.1);
}

.dome-grid__card-caption {
  z-index: 1;
  grid-column-gap: 0.5em;
  grid-row-gap: 0.5em;
  pointer-events: none;
  color: #ececec;
  transform-style: preserve-3d;
  background-image: linear-gradient(rgba(236, 236, 236, 0) 40%, rgba(8, 8, 8, 0.92));
  justify-content: space-between;
  align-items: flex-end;
  padding: 1.25em;
  transition-property: opacity;
  transition-duration: 0.35s;
  transition-timing-function: ease-out;
  display: flex;
  position: absolute;
  inset: 0%;
  transform: translateZ(0);
}

.dome-grid__card-title {
  margin-top: 0;
  margin-bottom: 0;
  font-size: 1em;
  line-height: 1.2;
}

.dome-grid__card-meta {
  opacity: 0.85;
  color: #b0b0b0;
  margin-top: 0;
  margin-bottom: 0;
  font-size: 0.75em;
  line-height: 1.2;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.dome-grid__card-meta.is--faded {
  opacity: 0.6;
}

.dome-grid__card-dim {
  z-index: 1;
  pointer-events: none;
  opacity: var(--dim);
  transform-style: preserve-3d;
  background-color: #0a0a0a;
  position: absolute;
  inset: 0%;
  transform: translateZ(0);
}

.dome-grid__detail-content {
  grid-row-gap: 1.5em;
  flex-flow: column;
  justify-content: center;
  align-items: flex-start;
  min-height: 100%;
  padding: 2.5em;
  display: flex;
}

.dome-grid__detail-title {
  letter-spacing: -0.02em;
  margin-top: 0;
  margin-bottom: 0;
  font-size: 2em;
  font-weight: 500;
  line-height: 1;
  position: relative;
}

.dome-grid__detail-title::after {
  content: "";
  display: block;
  width: 2.5em;
  height: 2px;
  margin-top: 0.85em;
  background: linear-gradient(90deg, #b0b0b0, transparent);
}

.dome-grid__flashlight {
  --fx: 50%;
  --fy: 50%;
  opacity: 0.7;
  pointer-events: none;
  -webkit-mask-image: radial-gradient(circle at var(--fx) var(--fy), transparent 25%, #000 75%);
  mask-image: radial-gradient(circle at var(--fx) var(--fy), transparent 25%, #000 75%);
  background-color: #0a0a0a;
  position: absolute;
  inset: 0%;
}

.dome-grid__cover {
  z-index: 20;
  pointer-events: none;
  background-color: #0f0f0f;
  position: absolute;
  inset: 0%;
}

.dome-grid__detail {
  z-index: 10;
  opacity: 0;
  pointer-events: none;
  color: #1b1b1b;
  top: 50%;
  left: calc(50% + var(--lightbox-gap, 0px) / 2);
  width: var(--lightbox-size, 70vh);
  height: var(--lightbox-size, 70vh);
  transition-property: opacity;
  transition-duration: 0.2s;
  transition-timing-function: ease-out;
  display: block;
  position: absolute;
  transform: translateY(-50%);
}

.dome-grid__detail[data-open] {
  opacity: 1;
  transition: opacity 0.35s ease-out 0.15s;
}

.dome-grid__detail-scroll {
  top: 0%;
  bottom: 0%;
  right: 0%;
  left: calc(0px - var(--lightbox-gap, 0px));
  overscroll-behavior: contain;
  touch-action: none;
  position: absolute;
  overflow-y: auto;
}

.dome-grid__detail[data-open] .dome-grid__detail-scroll {
  pointer-events: auto;
}

.dome-grid__card[data-lifted] .dome-grid__card-image-wrap {
  box-shadow: 0 40px 120px rgba(0, 0, 0, 0.55);
}

.dome-grid__card[data-lifted] .dome-grid__card-panel {
  box-shadow: 0 30px 90px rgba(0, 0, 0, 0.35);
}

.dome-grid__detail-close {
  position: absolute;
  top: 1.1em;
  right: 1.1em;
  z-index: 3;
  display: grid;
  place-items: center;
  width: 2.5em;
  height: 2.5em;
  border: 1px solid rgba(27, 27, 27, 0.18);
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.6);
  color: #1b1b1b;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease 0.15s, background-color 0.25s ease, border-color 0.25s ease;
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
}

.dome-grid__detail-close:hover {
  background: #ececea;
  border-color: rgba(27, 27, 27, 0.35);
}

.dome-grid__detail[data-open] .dome-grid__detail-close {
  opacity: 1;
  pointer-events: auto;
}

.dome-grid__detail-scroll .dome-grid__detail-content {
  justify-content: center;
  align-items: flex-start;
  padding: 3.5em 2.75em 3em;
}

.dome-grid__detail-scroll .dome-grid__detail-title {
  font-family: "Fraunces", Georgia, "Times New Roman", serif;
  font-weight: 500;
  font-size: 2.6em;
  letter-spacing: -0.03em;
  line-height: 1.02;
}

.dome-grid__detail-scroll .dome-grid__detail-meta {
  display: flex;
  align-items: center;
  column-gap: 0.6em;
  color: #8a8a8a;
  font-size: 0.72em;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  opacity: 1;
}

.dome-grid__detail-meta::before {
  content: "";
  width: 2em;
  height: 1px;
  background: #c4c4c4;
}

.dome-grid__detail-scroll .dome-grid__detail-text {
  color: #6f6f6f;
  font-size: 1em;
  line-height: 1.65;
  max-width: 34ch;
}

.dome-grid__detail-scroll .dome-grid__detail-text.is--faded {
  opacity: 1;
}

.dome-grid__detail-scroll::-webkit-scrollbar {
  width: 5px;
}

.dome-grid__detail-scroll::-webkit-scrollbar-thumb {
  background: rgba(27, 27, 27, 0.25);
  border-radius: 99px;
}

.dome-grid__detail-scroll::-webkit-scrollbar-track {
  background: transparent;
}

@media (orientation: portrait) {
  .dome-grid__card-panel {
    transform: translateY(calc((100% + var(--panel-gap)) * var(--slide)));
  }

  .dome-grid__detail {
    top: calc(50% + var(--lightbox-gap, 0px) / 2);
    left: 50%;
    width: var(--lightbox-size, 80vw);
    height: var(--lightbox-size, 80vw);
    transform: translateX(-50%);
  }

  .dome-grid__detail-scroll {
    left: 0;
    top: calc(0px - var(--lightbox-gap, 0px));
  }

  .dome-grid__detail-content {
    padding: 2em;
  }
}

@media (max-width: 767px) {
  .dome-grid__card .dome-grid__detail-text.is--faded {
    display: none;
  }
}

@media (orientation: portrait) {
  .dome-grid__detail-scroll .dome-grid__detail-content {
    padding: 2.5em 2em 2em;
  }

  .dome-grid__detail-scroll .dome-grid__detail-title {
    font-size: 2em;
  }
}
````

## Source — script.js
````javascript
function initInfiniteDomeGrid() {
  const stages = document.querySelectorAll("[data-dome-grid]");
  if (!stages.length) return;

  const columns = 4;
  const columnsMobile = 2;
  const mobileBreakpoint = 767;

  const bow = 0.15;
  const bulge = 0.25;
  const bulgeReach = 1.5;
  const torchRadius = 0.75;
  const torchRadiusTouch = 1.5;
  const parallax = 12;

  const lightboxSize = 0.7;
  const lightboxSizePortrait = 0.8;
  const lightboxPairMax = 0.9;

  const dragSpeed = 1.5;
  const dragSpeedTouch = 2.5;
  const clickSlop = 10;

  const panDuration = 0.6;
  const cursorDuration = 0.45;
  const lensDuration = 0.55;
  const openDuration = 0.4;

  const coverTimeout = 4000;

  const wrap = (min, max, v) => {
    const r = max - min;
    return min + (((v - min) % r + r) % r);
  };

  const clamp = (min, max, v) => Math.min(Math.max(v, min), max);

  const smooth = (t) => t * t * (3 - 2 * t);

  function setupInstance(stage) {
    const cards = stage.querySelector("[data-dome-grid-cards]");
    const flashlight = stage.querySelector("[data-dome-grid-flashlight]");
    const detail = stage.querySelector("[data-dome-grid-detail]");
    const detailScroll = stage.querySelector("[data-dome-grid-detail-scroll]");
    const cover = stage.querySelector("[data-dome-grid-cover]");
    const sources = [...cards.querySelectorAll("[data-dome-grid-card]")];
    if (!sources.length || stage.getAttribute("data-dome-grid") === "active") return;
    stage.setAttribute("data-dome-grid", "active");

    const x = { t: 0, c: 0 };
    const y = { t: 0, c: 0 };
    const size = { w: 0, h: 0, left: 0, top: 0 };
    const mid = { x: 0, y: 0 };
    const max = { x: 0, y: 0 };
    const margin = { x: 0, y: 0 };
    const cursor = { x: 0, y: 0, cx: 0, cy: 0 };
    const lens = { t: 0, c: 0 };
    const open = { cell: null, t: 0, c: 0 };
    const lit = { x: -1, y: -1 };
    const drag = { x: 0, y: 0, tx: 0, ty: 0 };

    const qx = gsap.quickTo(x, "c", { duration: panDuration, ease: "power2.out" });
    const qy = gsap.quickTo(y, "c", { duration: panDuration, ease: "power2.out" });
    const qcx = gsap.quickTo(cursor, "cx", { duration: cursorDuration, ease: "power2.out" });
    const qcy = gsap.quickTo(cursor, "cy", { duration: cursorDuration, ease: "power2.out" });
    const qLens = gsap.quickTo(lens, "c", { duration: lensDuration, ease: "power2.out" });
    const qOpen = gsap.quickTo(open, "c", { duration: openDuration, ease: "power2.out" });

    let cells = [];
    let card = 0;
    let gap = 0;
    let cols = 0;
    let rows = 0;
    let count = 0;
    let boxHalf = 0;
    let shift = 0;
    let hole = 0;
    let edge = 0;
    let darkness = 0;
    let portrait = false;
    let fine = false;
    let speed = dragSpeed;
    let dragging = false;
    let generation = 0;
    let timer = 0;

    const fill = (n) => {
      const items = [];
      for (let i = 0; i < n; i++) {
        const source = sources[i % sources.length];
        if (i < sources.length) {
          items.push(source);
          continue;
        }
        const clone = source.cloneNode(true);
        clone.setAttribute("aria-hidden", "true");
        items.push(clone);
      }
      cards.replaceChildren(...items);
    };

    const resetOpen = () => {
      if (!open.cell) return;
      open.cell.el.removeAttribute("data-active");
      open.cell.el.removeAttribute("data-lifted");
      if (open.cell.wrap) open.cell.wrap.style.borderRadius = "";
      open.cell.el.style.setProperty("--slide", "0");
      open.cell.el.style.setProperty("--dim", "0");
      open.cell = null;
      open.t = open.c = 0;
      detail.removeAttribute("data-open");
      detailScroll.replaceChildren();
    };

    const resize = async () => {
      const run = ++generation;
      const r = stage.getBoundingClientRect();
      if (!r.width || !r.height) return;
      size.w = r.width;
      size.h = r.height;
      size.left = r.left;
      size.top = r.top;
      resetOpen();

      const visible = size.w < mobileBreakpoint ? columnsMobile : columns;
      const period = size.w / visible;
      gap = parseFloat(getComputedStyle(cards).columnGap) || 0;
      card = period - gap;
      cols = visible + 2;
      cards.style.gridTemplateColumns = `repeat(${cols}, ${card}px)`;

      fine = matchMedia("(pointer: fine)").matches;
      speed = fine ? dragSpeed : dragSpeedTouch;
      mid.x = size.w / 2;
      mid.y = size.h / 2;
      portrait = size.h > size.w;

      const raw = getComputedStyle(stage).getPropertyValue("--panel-gap").trim();
      const unit = raw.endsWith("rem")
        ? parseFloat(getComputedStyle(document.documentElement).fontSize)
        : raw.endsWith("em")
          ? parseFloat(getComputedStyle(stage).fontSize)
          : 1;
      const panelGap = (parseFloat(raw) || 0) * unit;
      darkness = parseFloat(getComputedStyle(flashlight).opacity) || 0;

      const share = portrait ? lightboxSizePortrait : lightboxSize;
      const box = Math.min(
        Math.min(size.w, size.h) * share,
        (Math.max(size.w, size.h) * lightboxPairMax) / 2
      );
      const pair = (panelGap * box) / card;
      boxHalf = box / 2;
      shift = -(boxHalf + pair / 2);
      stage.style.setProperty("--lightbox-size", `${box}px`);
      stage.style.setProperty("--lightbox-gap", `${pair}px`);

      hole = period * (fine ? torchRadius : torchRadiusTouch);
      edge = hole * 2;
      const stops = [`transparent ${hole.toFixed(1)}px`];
      for (let i = 1; i <= 12; i++) {
        const t = i / 12;
        stops.push(
          `rgba(0,0,0,${smooth(t).toFixed(4)}) ${(hole + t * (edge - hole)).toFixed(1)}px`
        );
      }
      flashlight.style.maskImage = `radial-gradient(circle at var(--fx) var(--fy), ${stops.join(", ")})`;
      flashlight.style.webkitMaskImage = flashlight.style.maskImage;
      flashlight.style.setProperty("--fx", `${mid.x + cursor.cx}px`);
      flashlight.style.setProperty("--fy", `${mid.y + cursor.cy}px`);

      const need =
        Math.ceil(
          Math.max(cols * (Math.ceil(size.h / period) + 2), sources.length) / cols
        ) * cols;
      if (need !== count) fill((count = need));
      rows = count / cols;

      await new Promise(requestAnimationFrame);
      if (run !== generation) return;

      const items = [...cards.querySelectorAll("[data-dome-grid-card]")];
      items.forEach((el) => {
        el.style.transform = "translate3d(0, 0, 0)";
      });
      const origin = stage.getBoundingClientRect();
      cells = items.map((el) => {
        const b = el.getBoundingClientRect();
        return {
          el,
          img: el.querySelector("[data-dome-grid-image]"),
          wrap: el.querySelector(".dome-grid__card-image-wrap"),
          content: el.querySelector("[data-dome-grid-detail-content]"),
          end: { x: b.right - origin.left, y: b.bottom - origin.top },
          mid: { x: b.left - origin.left + b.width / 2, y: b.top - origin.top + b.height / 2 },
          out: true,
        };
      });

      max.x = cols * period;
      max.y = rows * period;
      margin.x = (max.x - size.w) / 2;
      margin.y = (max.y - size.h) / 2;
      render(true);
    };

    const warpPoint = (px, py) => {
      const nx = px / mid.x;
      const ny = py / mid.y;
      const f = 1 + bow * (nx * nx + ny * ny);
      let wx = px * f;
      let wy = py * f;
      if (lens.c > 0.001) {
        const dx = wx - cursor.cx;
        const dy = wy - cursor.cy;
        const reach = bulgeReach * (card + gap);
        const push = bulge * lens.c * Math.exp(-(dx * dx + dy * dy) / (reach * reach));
        wx += dx * push;
        wy += dy * push;
      }
      return { x: wx, y: wy };
    };

    const inset = (c, a, b, by) => {
      const la = Math.hypot(a.x - c.x, a.y - c.y) || 1;
      const lb = Math.hypot(b.x - c.x, b.y - c.y) || 1;
      return {
        x: c.x + (by * (a.x - c.x)) / la + (by * (b.x - c.x)) / lb,
        y: c.y + (by * (a.y - c.y)) / la + (by * (b.y - c.y)) / lb,
      };
    };

    const quad = (c) => {
      const ph = (card + gap) / 2;
      const tx = wrap(c.end.x + margin.x - max.x, c.end.x + margin.x, x.c);
      const ty = wrap(c.end.y + margin.y - max.y, c.end.y + margin.y, y.c);
      const ox = c.mid.x - tx - mid.x;
      const oy = c.mid.y - ty - mid.y;
      return {
        tl: warpPoint(ox - ph, oy - ph),
        tr: warpPoint(ox + ph, oy - ph),
        bl: warpPoint(ox - ph, oy + ph),
        br: warpPoint(ox + ph, oy + ph),
      };
    };

    const matrix = (c, q0, q1, q2, q3) => {
      const rx = c.mid.x - card / 2 - mid.x;
      const ry = c.mid.y - card / 2 - mid.y;
      const x0 = q0.x - rx, y0 = q0.y - ry;
      const x1 = q1.x - rx, y1 = q1.y - ry;
      const x2 = q2.x - rx, y2 = q2.y - ry;
      const x3 = q3.x - rx, y3 = q3.y - ry;
      const dx1 = x1 - x3, dy1 = y1 - y3;
      const dx2 = x2 - x3, dy2 = y2 - y3;
      const sx = x0 - x1 - x2 + x3;
      const sy = y0 - y1 - y2 + y3;
      const den = dx1 * dy2 - dx2 * dy1;
      const g = den ? (sx * dy2 - dx2 * sy) / den : 0;
      const h = den ? (dx1 * sy - sx * dy1) / den : 0;
      const a = x1 - x0 + g * x1;
      const b = x2 - x0 + h * x2;
      const d = y1 - y0 + g * y1;
      const e = y2 - y0 + h * y2;
      return `matrix3d(${a / card}, ${d / card}, 0, ${g / card}, ${b / card}, ${e / card}, 0, ${h / card}, 0, 0, 1, 0, ${x0}, ${y0}, 0, 1)`;
    };

    const cellMatrix = (c, q = quad(c)) => {
      const g2 = gap / 2;
      return matrix(
        c,
        inset(q.tl, q.tr, q.bl, g2),
        inset(q.tr, q.tl, q.br, g2),
        inset(q.bl, q.tl, q.br, g2),
        inset(q.br, q.bl, q.tr, g2)
      );
    };

    const dimAt = (q) => {
      const dx = (q.tl.x + q.br.x) / 2 - cursor.cx;
      const dy = (q.tl.y + q.br.y) / 2 - cursor.cy;
      const d = Math.hypot(dx, dy);
      return darkness * smooth(clamp(0, 1, (d - hole) / (edge - hole)));
    };

    const drift = (c, q, ease = 1) => {
      if (!c.img) return;
      const reach = bulgeReach * (card + gap);
      const px =
        clamp(-1, 1, (cursor.cx - (q.tl.x + q.br.x) / 2) / reach) * parallax * ease;
      const py =
        clamp(-1, 1, (cursor.cy - (q.tl.y + q.br.y) / 2) / reach) * parallax * ease;
      c.img.style.transform = `translate3d(${px}px, ${py}px, 0) scale(1.1)`;
    };

    const render = (all = false) => {
      const pad = card + gap;
      for (const c of cells) {
        const q = quad(c);

        if (c === open.cell) {
          const k = open.c;
          const h = boxHalf + gap / 2;
          const sx = portrait ? 0 : shift;
          const sy = portrait ? shift : 0;
          q.tl = { x: q.tl.x + (sx - h - q.tl.x) * k, y: q.tl.y + (sy - h - q.tl.y) * k };
          q.tr = { x: q.tr.x + (sx + h - q.tr.x) * k, y: q.tr.y + (sy - h - q.tr.y) * k };
          q.bl = { x: q.bl.x + (sx - h - q.bl.x) * k, y: q.bl.y + (sy + h - q.bl.y) * k };
          q.br = { x: q.br.x + (sx + h - q.br.x) * k, y: q.br.y + (sy + h - q.br.y) * k };
          c.out = false;
          c.el.style.transform = cellMatrix(c, q);
          c.el.style.setProperty("--dim", `${dimAt(q) * (1 - k)}`);
          if (c.wrap) {
            const m = 1 - k;
            c.wrap.style.borderRadius = portrait
              ? `1em 1em ${m.toFixed(2)}em ${m.toFixed(2)}em`
              : `1em ${m.toFixed(2)}em ${m.toFixed(2)}em 1em`;
          }
          drift(c, q, 1 - k);
          continue;
        }

        const visible =
          Math.max(q.tl.x, q.tr.x, q.bl.x, q.br.x) > -mid.x - pad &&
          Math.min(q.tl.x, q.tr.x, q.bl.x, q.br.x) < mid.x + pad &&
          Math.max(q.tl.y, q.tr.y, q.bl.y, q.br.y) > -mid.y - pad &&
          Math.min(q.tl.y, q.tr.y, q.bl.y, q.br.y) < mid.y + pad;

        if (visible || all) {
          c.out = false;
        } else if (c.out) {
          continue;
        } else {
          c.out = true;
        }

        c.el.style.transform = cellMatrix(c, q);
        drift(c, q);
      }
    };

    const tick = () => {
      if (!stage.isConnected) return destroy();

      qx(x.t);
      qy(y.t);
      qcx(cursor.x);
      qcy(cursor.y);

      const fx = Math.round(mid.x + cursor.cx);
      const fy = Math.round(mid.y + cursor.cy);
      if (fx !== lit.x || fy !== lit.y) {
        lit.x = fx;
        lit.y = fy;
        flashlight.style.setProperty("--fx", `${fx}px`);
        flashlight.style.setProperty("--fy", `${fy}px`);
      }

      const vel = Math.abs(x.t - x.c) + Math.abs(y.t - y.c);
      const calm = 1 / (1 + vel / 40);
      qLens(lens.t * calm);
      qOpen(open.t);

      if (open.cell) {
        open.cell.el.style.setProperty(
          "--slide",
          `${clamp(0, 1, (open.c - 0.25) / 0.75)}`
        );
        if (!open.t && open.c < 0.001) resetOpen();
      }

      const still =
        Math.abs(x.t - x.c) < 0.1 &&
        Math.abs(y.t - y.c) < 0.1 &&
        Math.abs(cursor.x - cursor.cx) < 0.1 &&
        Math.abs(cursor.y - cursor.cy) < 0.1 &&
        Math.abs(lens.t * calm - lens.c) < 0.001 &&
        Math.abs(open.t - open.c) < 0.0005;

      if (!still) render();
    };

    const openCard = (cell) => {
      open.cell = cell;
      open.t = 1;
      cell.el.style.setProperty("--dim", `${dimAt(quad(cell))}`);
      cell.el.setAttribute("data-lifted", "");
      cell.el.setAttribute("data-active", "");
      if (cell.content) {
        const clone = cell.content.cloneNode(true);
        const meta = cell.el.querySelector(".dome-grid__card-meta");
        const metaEl = document.createElement("p");
        metaEl.className = "dome-grid__detail-meta";
        metaEl.textContent = meta ? meta.textContent.trim() : "";
        clone.insertBefore(metaEl, clone.firstElementChild);
        detailScroll.replaceChildren(clone);
      }
      detailScroll.scrollTop = 0;
      detail.setAttribute("data-open", "");
    };

    const closeCard = () => {
      if (!open.cell || !open.t) return;
      open.t = 0;
      open.cell.el.removeAttribute("data-active");
      detail.removeAttribute("data-open");
    };

    const onWheel = (e) => {
      if (open.t) return;
      e.preventDefault();
      const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? size.h : 1;
      x.t += e.deltaX * unit;
      y.t += e.deltaY * unit;
    };

    const onKey = (e) => {
      if (e.key === "Escape") closeCard();
      if (open.t) return;
      if (e.key === " ") {
        e.preventDefault();
        y.t += mid.y * 1.6 * (e.shiftKey ? -1 : 1);
      }
      if (e.key.startsWith("Arrow")) {
        e.preventDefault();
        const step = card + gap;
        if (e.key === "ArrowLeft") x.t -= step;
        if (e.key === "ArrowRight") x.t += step;
        if (e.key === "ArrowUp") y.t -= step;
        if (e.key === "ArrowDown") y.t += step;
      }
    };

    const onDown = (e) => {
      if (e.target.closest("[data-dome-grid-detail]")) return;
      dragging = true;
      stage.setAttribute("data-dragging", "");
      closeCard();
      drag.x = e.clientX;
      drag.y = e.clientY;
      drag.tx = x.t;
      drag.ty = y.t;
    };

    const onMove = (e) => {
      if (fine) {
        cursor.x = e.clientX - size.left - mid.x;
        cursor.y = e.clientY - size.top - mid.y;
        lens.t = 1;
      }
      if (!dragging || open.t) return;
      x.t = drag.tx - (e.clientX - drag.x) * speed;
      y.t = drag.ty - (e.clientY - drag.y) * speed;
    };

    const onUp = (e) => {
      if (!dragging) return;
      dragging = false;
      stage.removeAttribute("data-dragging");
      if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) > clickSlop) return;
      if (open.cell) return;
      const hit = e.target.closest?.("[data-dome-grid-card]");
      const cell = hit && cells.find((c) => c.el === hit);
      if (cell) openCard(cell);
    };

    const onLeave = () => {
      lens.t = 0;
    };

    const onResize = () => {
      clearTimeout(timer);
      timer = setTimeout(resize, 100);
    };

    const close = detail.querySelector("[data-dome-grid-close]");
    const onCloseClick = (e) => {
      e.stopPropagation();
      closeCard();
    };

    const destroy = () => {
      gsap.ticker.remove(tick);
      gsap.killTweensOf([x, y, cursor, lens, open]);
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onUp);
      window.removeEventListener("keydown", onKey);
      window.removeEventListener("resize", onResize);
      close.removeEventListener("click", onCloseClick);
      stage.setAttribute("data-dome-grid", "");
    };

    const start = async () => {
      await resize();

      stage.addEventListener("pointerdown", onDown);
      stage.addEventListener("pointerleave", onLeave);
      stage.addEventListener("wheel", onWheel, { passive: false });
      window.addEventListener("pointermove", onMove);
      window.addEventListener("pointerup", onUp);
      window.addEventListener("pointercancel", onUp);
      window.addEventListener("keydown", onKey);
      window.addEventListener("resize", onResize);
      close.addEventListener("click", onCloseClick);

      gsap.ticker.add(tick);

      const images = [...stage.querySelectorAll("img")];
      await Promise.race([
        Promise.all(
          images.map(
            (img) =>
              img.complete ||
              new Promise((r) => {
                img.addEventListener("load", r, { once: true });
                img.addEventListener("error", r, { once: true });
              })
          )
        ),
        new Promise((r) => setTimeout(r, coverTimeout)),
      ]);
      stage.setAttribute("data-loaded", "");
      gsap.to(cover, {
        opacity: 0,
        duration: 0.3,
        ease: "power1.out",
        pointerEvents: "none",
      });
    };

    start();
  }

  stages.forEach(setupInstance);
}

document.addEventListener("DOMContentLoaded", () => {
  initInfiniteDomeGrid();
});
````
