# Infinite Orbit Gallery (Click to Expand)

> A WebGL image constellation that orbits infinitely — scroll or drag to spin, hover to focus, click a frame to expand it.

**Definition:** Infinite Orbit Gallery (Click to Expand) 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-04
- Canonical page: https://html.bosconvertpro.com/collection/infinite-orbit-gallery-click-to-expand
- Full-page preview: https://html.bosconvertpro.com/collection/infinite-orbit-gallery-click-to-expand/full
- Live demo: https://html.bosconvertpro.com/demos/infinite-orbit-gallery-click-to-expand/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/infinite-orbit-gallery-click-to-expand/index.md
- License: https://html.bosconvertpro.com/terms

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

## FAQ
### What is Infinite Orbit Gallery (Click to Expand)?

Infinite Orbit Gallery (Click to Expand) is a sliders UI animation from collections.html. A WebGL image constellation that orbits infinitely — scroll or drag to spin, hover to focus, click a frame to expand it.

### What is Infinite Orbit Gallery (Click to Expand) built with?

Infinite Orbit Gallery (Click to Expand) 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 Orbit Gallery (Click to Expand) in my project?

Open the animation page (https://html.bosconvertpro.com/collection/infinite-orbit-gallery-click-to-expand), 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-orbit-gallery-click-to-expand/index.html.

### Is Infinite Orbit Gallery (Click to Expand) free to use?

Yes. The Infinite Orbit Gallery (Click to Expand) 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 Orbit Gallery (Click to Expand)</title>
    <meta
      name="description"
      content="Infinite orbiting image constellation with WebGL and GSAP — scroll to spin, hover to focus, click any image to expand it."
    />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link rel="preconnect" href="https://cdnjs.cloudflare.com" />
    <link rel="preconnect" href="https://cdn.jsdelivr.net" />
    <link rel="preconnect" href="https://images.unsplash.com" />
    <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="cipher-hero" data-cipher-hero aria-label="Cipher hero image constellation">
      <canvas id="home_canvas" data-cipher-canvas></canvas>

      <div class="works_infos" aria-hidden="true">
        <div class="l"><p class="work_index" id="worksIndex">FOR CULTURE AND ITS COUNTER</p></div>
        <div class="m"><p class="work_label" id="worksLabel">CONSTELLATION</p></div>
        <div class="r"><span class="hint">Scroll / Drag — Click to open</span></div>
      </div>

      <div class="detail" data-detail aria-hidden="true" role="dialog" aria-modal="true" aria-label="Work detail">
        <div class="detail__backdrop" data-detail-backdrop></div>
        <figure class="detail__card" data-detail-card>
          <div class="detail__media">
            <img data-detail-img alt="" draggable="false" />
          </div>
          <figcaption class="detail__body">
            <p class="detail__index" data-detail-index>001</p>
            <h2 class="detail__title" data-detail-title>Title</h2>
            <p class="detail__tag" data-detail-tag>Film</p>
            <p class="detail__text" data-detail-text>Description</p>
            <p class="detail__meta" data-detail-meta>Location</p>
          </figcaption>
          <button type="button" class="detail__close" data-detail-close aria-label="Close detail">
            <svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
              <path d="M1.5 1.5l13 13M14.5 1.5l-13 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
            </svg>
          </button>
        </figure>
        <img class="detail__flyer" data-detail-flyer alt="" aria-hidden="true" draggable="false" />
      </div>
    </main>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/0.158.0/three.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/CustomEase.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

:root {
  --cipher-bg: #0b0b0c;
  --cipher-fg: #e9eae4;
  --cipher-fg-dim: rgba(233, 234, 228, 0.55);
  --cipher-font: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
}

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

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

.cipher-hero {
  position: relative;
  width: 100%;
  height: 100svh;
  height: 100dvh;
  overflow: hidden;
  background: var(--cipher-bg);
}

.cipher-hero canvas {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100vh;
  height: 100svh;
  display: block;
  cursor: grab;
  touch-action: pan-y;
}

.cipher-hero canvas:active {
  cursor: grabbing;
}

.works_infos {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 5;
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  align-items: center;
  padding: 16px 20px;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.12em;
  line-height: 19px;
  text-transform: uppercase;
  color: var(--cipher-fg);
  mix-blend-mode: difference;
  pointer-events: none;
  user-select: none;
}

.works_infos .l {
  display: flex;
  justify-content: flex-start;
}

.works_infos .m {
  display: flex;
  justify-content: center;
  text-align: center;
}

.works_infos .r {
  display: flex;
  justify-content: flex-end;
}

.work_index,
.work_label {
  white-space: nowrap;
  min-height: 19px;
}

.hint {
  color: var(--cipher-fg-dim);
  letter-spacing: 0.12em;
}

@media (max-width: 768px) {
  .works_infos {
    grid-template-columns: 1fr;
    gap: 2px;
    padding: 14px 20px;
  }

  .works_infos .m {
    display: none;
  }

  .works_infos .r {
    justify-content: flex-start;
  }

  .hint {
    font-size: 10px;
  }
}

.detail {
  position: fixed;
  inset: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2vmin;
  visibility: hidden;
  overflow-y: auto;
}

.detail__backdrop {
  position: fixed;
  inset: 0;
  background: rgba(5, 5, 6, 0.72);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  opacity: 0;
  cursor: zoom-out;
}

.detail__card {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-columns: 1.55fr 1fr;
  width: min(1440px, 96vw);
  max-height: 96vh;
  max-height: 96svh;
  background: #101012;
  border: 1px solid rgba(233, 234, 228, 0.14);
  box-shadow: 0 40px 120px rgba(0, 0, 0, 0.6);
  overflow: hidden;
  visibility: hidden;
  opacity: 0;
}

.detail__media {
  position: relative;
  min-height: 600px;
  background: #000;
  overflow: hidden;
}

.detail__media img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  user-select: none;
  -webkit-user-drag: none;
}

.detail__body {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 16px;
  padding: clamp(28px, 4.5vw, 64px);
}

.detail__index {
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.22em;
  color: var(--cipher-fg-dim);
}

.detail__title {
  font-size: clamp(32px, 4.6vw, 58px);
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.02;
  text-transform: uppercase;
  text-wrap: balance;
}

.detail__tag {
  display: inline-block;
  align-self: flex-start;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  padding: 7px 12px;
  border: 1px solid rgba(233, 234, 228, 0.35);
  border-radius: 999px;
  color: var(--cipher-fg);
}

.detail__text {
  font-size: 15px;
  line-height: 1.65;
  letter-spacing: -0.005em;
  color: rgba(233, 234, 228, 0.82);
  max-width: 36ch;
}

.detail__meta {
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--cipher-fg-dim);
}

.detail__close {
  position: absolute;
  top: 12px;
  right: 12px;
  z-index: 3;
  width: 36px;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid rgba(233, 234, 228, 0.4);
  border-radius: 50%;
  background: rgba(11, 11, 12, 0.55);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  color: var(--cipher-fg);
  font-family: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: transform 0.45s cubic-bezier(0.16, 1, 0.3, 1), background 0.3s, color 0.3s;
}

.detail__close:hover {
  background: var(--cipher-fg);
  color: #0b0b0c;
  transform: rotate(90deg) scale(1.06);
}

.detail__close:focus-visible {
  outline: 2px solid var(--cipher-fg);
  outline-offset: 3px;
}

.detail__close svg {
  width: 13px;
  height: 13px;
  display: block;
}

.detail__flyer {
  position: fixed;
  z-index: 2;
  object-fit: cover;
  display: block;
  pointer-events: none;
  visibility: hidden;
  user-select: none;
  -webkit-user-drag: none;
  will-change: transform;
}

@media (max-width: 768px) {
  .detail {
    padding: 12px;
    align-items: flex-start;
  }

  .detail__card {
    grid-template-columns: 1fr;
    width: min(720px, 96vw);
    max-height: none;
    margin: auto 0;
  }

  .detail__media {
    min-height: 0;
    aspect-ratio: 4 / 3;
  }

  .detail__body {
    gap: 12px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cipher-hero canvas {
    cursor: default;
  }
}
````

## Source — script.js
````javascript
(function () {
  "use strict";

  var RX_DESK = 0.58;
  var RY_DESK = 0.32;
  var TILT_DESK = Math.PI / 7;
  var RX_MOB = 0.43;
  var RY_MOB = 0.23;
  var TILT_MOB = Math.PI / 6;

  var PLANE_H_DESK = 0.21;
  var PLANE_H_MOB = 0.17;

  var BASE_SPEED = 0.015;
  var CAM_FOV = 70;
  var CAM_Z_START = 4;
  var CAM_Z_REST = 1;

  var SCRAMBLE_GLYPHS = "ɔıdɥǝɹ";
  var INDEX_BASE = "FOR CULTURE AND ITS COUNTER";
  var LABEL_BASE = "CONSTELLATION";

  var FLIGHT_EASE = "expo.inOut";

  var VERT_SHADER = [
    "varying vec2 vUv;",
    "void main() {",
    "  vUv = uv;",
    "  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);",
    "}",
  ].join("\n");

  var FRAG_SHADER = [
    "varying vec2 vUv;",
    "uniform float uTime;",
    "uniform sampler2D uTexture;",
    "uniform float uOpacity;",
    "uniform float uGrayscale;",
    "uniform float uZoom;",
    "uniform float uBlack;",
    "void main() {",
    "  vec2 uv = (vUv - 0.5) / uZoom + 0.5;",
    "  vec4 color = texture2D(uTexture, uv);",
    "  float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));",
    "  vec3 finalColor = mix(color.rgb, vec3(gray), uGrayscale);",
    "  vec3 c = mix(finalColor, vec3(0.0), uBlack);",
    "  gl_FragColor = vec4(c, color.a * uOpacity);",
    "}",
  ].join("\n");

  var WORKS = [
    { title: "EVIDENS DE BEAUTE", src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4", tag: "Film", place: "Swiss Alps", year: "2026", desc: "First light over the ridge — stillness, scale and thin air." },
    { title: "THE CIRCLE", src: "https://images.unsplash.com/photo-1469474968028-56623f02e42e", tag: "Stills", place: "Dolomites", year: "2025", desc: "A valley caught mid-breath as the sun breaks the treeline." },
    { title: "UNEKUAL", src: "https://images.unsplash.com/photo-1447752875215-b2761acb3c5d", tag: "Film", place: "Black Forest", year: "2026", desc: "Fog, pine and patience — nature running at half speed." },
    { title: "THE DAZZLING", src: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e", tag: "Stills", place: "Highlands", year: "2025", desc: "Low sun on still water. No set design required." },
    { title: "DUALIS", src: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05", tag: "Film", place: "Lofoten", year: "2026", desc: "Two weathers in one frame — storm leaving, light arriving." },
    { title: "THE ULTIMATE SHIELD", src: "https://images.unsplash.com/photo-1501785888041-af3ef285b470", tag: "Stills", place: "Faroe Islands", year: "2025", desc: "Cliffs against the Atlantic, shot between gusts." },
    { title: "BDK", src: "https://images.unsplash.com/photo-1472214103451-9374bd1c798e", tag: "Film", place: "Tuscany", year: "2026", desc: "Golden hour over open fields — warmth you can hear." },
    { title: "LG", src: "https://images.unsplash.com/photo-1518837695005-2083093ee35b", tag: "Stills", place: "Azores", year: "2025", desc: "The ocean, mid-sentence. Salt, spray, shutter." },
    { title: "COUNTER CULTURE", src: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e", tag: "Film", place: "Algarve", year: "2026", desc: "Barefoot luxury — sand, tide and late light." },
    { title: "EDB GAMME EXTREME", src: "https://images.unsplash.com/photo-1519681393784-d120267933ba", tag: "Stills", place: "Norway", year: "2025", desc: "Night shift above the treeline. Stars doing overtime." },
    { title: "MARLY", src: "https://images.unsplash.com/photo-1433086966358-54859d0ed716", tag: "Film", place: "Iceland", year: "2026", desc: "Falling water, long exposure, zero dialogue." },
    { title: "MARLY SHOOT STUDIO", src: "https://images.unsplash.com/photo-1502082553048-f009c37129b9", tag: "Stills", place: "Jura", year: "2025", desc: "Green on green — a forest with the contrast up." },
    { title: "MOVIE & CAPSULES", src: "https://images.unsplash.com/photo-1493246507139-91e8fad9978e", tag: "Film", place: "Canada", year: "2026", desc: "Mirror lake at dawn. Nature's own wide shot." },
  ];

  function waitForLibs(callback, attempt) {
    attempt = attempt || 0;
    if (typeof THREE !== "undefined" && typeof gsap !== "undefined") {
      callback();
      return;
    }
    if (attempt < 200) {
      setTimeout(function () {
        waitForLibs(callback, attempt + 1);
      }, 50);
      return;
    }
    console.error("[Cipher Hero] THREE.js / GSAP failed to load from CDN.");
  }

  function init() {
    var canvas = document.querySelector("[data-cipher-canvas]");
    if (!canvas) return;
    if (typeof CustomEase !== "undefined") {
      gsap.registerPlugin(CustomEase);
      try {
        CustomEase.create("cipherFlight", "M0,0 C0.7,0 0.25,1 1,1");
        FLIGHT_EASE = "cipherFlight";
      } catch (err) {
        FLIGHT_EASE = "expo.inOut";
      }
    }
    var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    new CipherOrbit(canvas, WORKS, { reducedMotion: reduced });
  }

  function sizedUrl(src, isMobile) {
    if (!src) return src;
    var sep = src.indexOf("?") !== -1 ? "&" : "?";
    var w = isMobile ? 300 : 600;
    var h = isMobile ? 200 : 400;
    return (
      src +
      sep +
      "w=" +
      w +
      "&h=" +
      h +
      "&q=85&fm=webp&fit=crop&crop=entropy"
    );
  }

  function scramble(from, to, onUpdate) {
    var timers = [];
    var n = Math.max(from.length, to.length);
    var base = from.padEnd(n, " ").split("");
    var target = to.padEnd(n, " ").split("");
    var order = base
      .map(function (_, idx) {
        return idx;
      })
      .filter(function (idx) {
        return base[idx] !== " " || target[idx] !== " ";
      })
      .sort(function () {
        return Math.random() - 0.5;
      });
    order.forEach(function (idx, k) {
      var delay = k * (30 + Math.random() * 25);
      timers.push(
        setTimeout(function () {
          base[idx] =
            SCRAMBLE_GLYPHS[Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)];
          onUpdate(base.join("").replace(/\s+$/, ""));
        }, delay)
      );
      timers.push(
        setTimeout(function () {
          base[idx] = target[idx] || " ";
          onUpdate(base.join("").replace(/\s+$/, ""));
        }, delay + 100)
      );
    });
    return timers;
  }

  function CipherOrbit(canvas, works, opts) {
    opts = opts || {};
    this.canvas = canvas;
    this.reducedMotion = !!opts.reducedMotion;

    this.isMobile = window.innerWidth <= 768;
    this.rx = this.isMobile ? RX_MOB : RX_DESK;
    this.ry = this.isMobile ? RY_MOB : RY_DESK;
    this.tilt = this.isMobile ? TILT_MOB : TILT_DESK;
    this.planeH = this.isMobile ? PLANE_H_MOB : PLANE_H_DESK;

    this.time = 0.8;
    this.speed = this.reducedMotion ? 0 : 0.5;
    this.baseSpeed = this.reducedMotion ? 0 : BASE_SPEED;
    this.direction = 1;
    this.scrollBonus = 0;
    this.motionScale = 1;
    this.introPlaying = true;
    this.outroPlaying = false;
    this.zCounter = 0;
    this.lastHovered = -1;
    this.scrambleTimers = [];

    this.works = works.map(function (w, i) {
      return {
        src: w.src,
        isVideo: !!w.isVideo,
        title: w.title,
        tag: w.tag || "Film",
        place: w.place || "",
        year: w.year || "",
        desc: w.desc || "",
        index: String(i + 1).padStart(3, "0"),
      };
    });
    this.indexEl = document.getElementById("worksIndex");
    this.labelEl = document.getElementById("worksLabel");
    this.infosEl = document.querySelector(".works_infos");

    this.detailOpen = false;
    this.detailAnimating = false;
    this.closingDetail = false;
    this.detailShown = false;
    this.detailIndex = -1;
    this.detailMesh = null;
    this.detailTl = null;
    this.detailEl = document.querySelector("[data-detail]");
    this.detailBackdrop = document.querySelector("[data-detail-backdrop]");
    this.detailCard = document.querySelector("[data-detail-card]");
    this.detailMedia = document.querySelector(".detail__media");
    this.detailImg = document.querySelector("[data-detail-img]");
    this.detailFlyer = document.querySelector("[data-detail-flyer]");
    this.detailLines = [
      "[data-detail-index]",
      "[data-detail-title]",
      "[data-detail-tag]",
      "[data-detail-text]",
      "[data-detail-meta]",
      "[data-detail-close]",
    ].map(function (sel) {
      return document.querySelector(sel);
    });

    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(
      CAM_FOV,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    );
    this.camera.position.z = CAM_Z_START;

    this.renderer = new THREE.WebGLRenderer({
      canvas: canvas,
      antialias: true,
      alpha: true,
    });
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));

    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2(9999, 9999);
    this.clock = new THREE.Clock();
    this.groups = [];
    this.meshes = [];
    this.videos = [];

    this.lut = this.buildLUT();

    this._onResize = this.resize.bind(this);
    this._onMouseMove = this.onMouseMove.bind(this);
    this._onWheel = this.onWheel.bind(this);
    this._onClick = this.onClick.bind(this);
    this._onKeydown = this.onKeydown.bind(this);
    this._closeDetail = this.closeDetail.bind(this);
    this._render = this.render.bind(this);

    window.addEventListener("resize", this._onResize);
    window.addEventListener("mousemove", this._onMouseMove);
    window.addEventListener("wheel", this._onWheel, { passive: true });
    window.addEventListener("click", this._onClick);
    window.addEventListener("keydown", this._onKeydown);
    if (this.detailBackdrop) {
      this.detailBackdrop.addEventListener("click", this._closeDetail);
    }
    var closeBtn = document.querySelector("[data-detail-close]");
    if (closeBtn) closeBtn.addEventListener("click", this._closeDetail);
    this.bindDrag();

    this.load().then(this.afterLoad.bind(this));
  }

  CipherOrbit.prototype.buildLUT = function () {
    var pts = [];
    var total = 0;
    var i, a, ex, ey, dx, dy;
    for (i = 0; i <= 1000; i++) {
      a = (i / 1000) * Math.PI * 2;
      ex = Math.cos(a) * this.rx;
      ey = Math.sin(a) * this.ry;
      var x = ex * Math.cos(this.tilt) - ey * Math.sin(this.tilt);
      var y = ex * Math.sin(this.tilt) + ey * Math.cos(this.tilt);
      if (i > 0) {
        dx = x - pts[i - 1].x;
        dy = y - pts[i - 1].y;
        total += Math.sqrt(dx * dx + dy * dy);
      }
      pts.push({ x: x, y: y, length: total });
    }
    pts.forEach(function (p) {
      p.t = total > 0 ? p.length / total : 0;
    });
    return pts;
  };

  CipherOrbit.prototype.ellipsePoint = function (t) {
    var s = ((t % 1) + 1) % 1;
    var lut = this.lut;
    var lo = 0;
    var hi = lut.length - 1;
    var mid;
    while (lo < hi - 1) {
      mid = Math.floor((lo + hi) / 2);
      if (lut[mid].t < s) lo = mid;
      else hi = mid;
    }
    var p0 = lut[lo];
    var p1 = lut[hi];
    var span = p1.t - p0.t;
    var k = span > 0 ? (s - p0.t) / span : 0;
    return { x: p0.x + (p1.x - p0.x) * k, y: p0.y + (p1.y - p0.y) * k };
  };

  CipherOrbit.prototype.loadTexture = function (src) {
    var loader = new THREE.TextureLoader();
    loader.setCrossOrigin("anonymous");
    var url = sizedUrl(src, this.isMobile);
    return loader.loadAsync(url).then(function (tex) {
      if (THREE.SRGBColorSpace && "colorSpace" in tex) {
        tex.colorSpace = THREE.SRGBColorSpace;
      }
      tex.minFilter = THREE.LinearFilter;
      tex.generateMipmaps = false;
      return tex;
    });
  };

  CipherOrbit.prototype.loadVideoTexture = function (src) {
    var self = this;
    return new Promise(function (resolve) {
      var v = document.createElement("video");
      v.crossOrigin = "anonymous";
      v.muted = true;
      v.loop = true;
      v.playsInline = true;
      v.autoplay = true;
      v.preload = "auto";
      v.oncanplay = function () {
        v.play()
          .catch(function () {})
          .then(function () {
            var tex = new THREE.VideoTexture(v);
            if (THREE.SRGBColorSpace && "colorSpace" in tex) {
              tex.colorSpace = THREE.SRGBColorSpace;
            }
            tex.minFilter = THREE.LinearFilter;
            tex.generateMipmaps = false;
            self.videos.push(v);
            resolve({ texture: tex, video: v });
          });
      };
      v.onerror = function () {

        self.loadTexture(WORKS[0].src).then(function (tex) {
          resolve({ texture: tex, video: null });
        });
      };
      v.src = src;
    });
  };

  CipherOrbit.prototype.load = function () {
    var self = this;
    var jobs = this.works.map(function (w) {
      if (w.isVideo) return self.loadVideoTexture(w.src);
      return self.loadTexture(w.src).then(function (tex) {
        return { texture: tex, video: null };
      });
    });
    return Promise.all(jobs).then(function (results) {
      results.forEach(function (res, i) {
        var tex = res.texture;
        var img = tex.image || {};
        var w = img.videoWidth || img.width || 16;
        var h = img.videoHeight || img.height || 9;
        var aspect = w / h || 16 / 9;
        var ph = self.planeH;
        var pw = ph * aspect;

        var mat = new THREE.ShaderMaterial({
          uniforms: {
            uTime: { value: 0 },
            uTexture: { value: tex },
            uOpacity: { value: 0 },
            uGrayscale: { value: 0 },
            uZoom: { value: 1.2 },
            uBlack: { value: 0 },
          },
          vertexShader: VERT_SHADER,
          fragmentShader: FRAG_SHADER,
          transparent: true,
        });
        var mesh = new THREE.Mesh(new THREE.PlaneGeometry(pw, ph), mat);
        var group = new THREE.Group();
        group.add(mesh);
        group.userData.timeOffset = i / results.length + 0.5;
        self.scene.add(group);
        self.groups.push(group);
        self.meshes.push(mesh);
      });
    });
  };

  CipherOrbit.prototype.afterLoad = function () {
    this.renderer.render(this.scene, this.camera);
    if (this.reducedMotion) {

      this.camera.position.z = CAM_Z_REST;
      this.groups.forEach(function (g, i, arr) {
        g.userData.timeOffset = i / arr.length;
        g.children[0].material.uniforms.uOpacity.value = 1;
      });
      this.introPlaying = false;
    } else {
      this.introAnimation();
    }
    requestAnimationFrame(this._render);
  };

  CipherOrbit.prototype.introAnimation = function () {
    var self = this;
    gsap.to(this.camera.position, {
      z: CAM_Z_REST,
      duration: 1.6,
      ease: "power2.out",
      onComplete: function () {
        self.introPlaying = false;
      },
    });
    this.speed = 0.5;
    this.direction = 1;
    gsap.to(this, {
      speed: this.baseSpeed,
      duration: 3.3,
      ease: "power1.out",
      onComplete: function () {
        self.scrollBonus = 0;
      },
    });
    var n = this.groups.length;
    this.groups.forEach(function (g, s) {
      var mesh = g.children[0];
      mesh.material.uniforms.uOpacity.value = 0;
      var target = s / n;
      var rev = n - 1 - s;
      g.userData.timeOffset = target + 0.5;
      gsap.to(g.userData, {
        timeOffset: target,
        duration: 1.8,
        ease: "expo.out",
        delay: rev * 0.045,
      });
      gsap.to(mesh.material.uniforms.uOpacity, {
        value: 1,
        duration: 0.65,
        ease: "none",
        delay: rev * 0.082,
      });
    });
  };

  CipherOrbit.prototype.onWheel = function (e) {
    if (this.outroPlaying || this.introPlaying || this.reducedMotion || this.detailOpen) return;
    var delta = (e.deltaY || 0) + (e.deltaX || 0);
    if (!delta) return;
    this.direction = delta >= 0 ? 1 : -1;
    var bonus = delta * 0.0022;
    bonus = Math.max(-0.5, Math.min(0.5, bonus));
    this.scrollBonus = bonus;
  };

  CipherOrbit.prototype.bindDrag = function () {
    var self = this;
    var down = false;
    var lastX = 0;
    var lastY = 0;
    this.canvas.addEventListener("pointerdown", function (e) {
      down = true;
      lastX = e.clientX;
      lastY = e.clientY;
      self.canvas.setPointerCapture && self.canvas.setPointerCapture(e.pointerId);
    });
    this.canvas.addEventListener("pointermove", function (e) {
      if (!down || self.introPlaying || self.reducedMotion || self.detailOpen) return;
      var dx = e.clientX - lastX;
      var dy = e.clientY - lastY;
      lastX = e.clientX;
      lastY = e.clientY;
      var delta = dx + dy;
      if (!delta) return;
      self.direction = delta >= 0 ? -1 : 1;
      var bonus = delta * 0.003;
      self.scrollBonus = Math.max(-0.6, Math.min(0.6, bonus));
      self.time -= delta * 0.0011;
    });
    ["pointerup", "pointercancel", "pointerleave"].forEach(function (evt) {
      self.canvas.addEventListener(evt, function () {
        down = false;
      });
    });

    this.canvas.addEventListener(
      "touchmove",
      function (e) {
        e.preventDefault();
      },
      { passive: false }
    );
  };

  CipherOrbit.prototype.onMouseMove = function (e) {
    this.mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
    this.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
  };

  CipherOrbit.prototype.onClick = function () {

    if (this.detailOpen || this.detailAnimating || this.introPlaying) return;
    if (this.lastHovered < 0) return;
    this.openDetail(this.lastHovered);
  };

  CipherOrbit.prototype.onKeydown = function (e) {
    if ((e.key === "Escape" || e.key === "Esc") && this.detailOpen) {
      this.closeDetail();
    }
  };

  function detailUrl(src) {
    if (!src) return src;
    var sep = src.indexOf("?") !== -1 ? "&" : "?";
    return src + sep + "w=1200&h=800&q=85&fm=webp&fit=crop&crop=entropy";
  }

  CipherOrbit.prototype.thumbRect = function (mesh) {
    var box = new THREE.Box3().setFromObject(mesh);
    var w = window.innerWidth;
    var h = window.innerHeight;
    var min = box.min.clone().project(this.camera);
    var max = box.max.clone().project(this.camera);
    var left = ((min.x * 0.5 + 0.5) * w);
    var top = ((-max.y * 0.5 + 0.5) * h);
    var width = ((max.x - min.x) * 0.5 * w);
    var height = ((max.y - min.y) * 0.5 * h);
    if (width < 4 || height < 4) return null;
    return { left: left, top: top, width: width, height: height };
  };

  CipherOrbit.prototype.openDetail = function (idx) {
    if (this.detailOpen || this.detailAnimating || this.introPlaying) return;
    var mesh = this.meshes[idx];
    if (!mesh) return;
    var rect = this.thumbRect(mesh);
    if (!rect) return;
    var work = this.works[idx];
    var self = this;

    this.detailAnimating = true;
    this.detailIndex = idx;
    this.detailOpen = true;
    this.detailMesh = mesh;

    gsap.to(mesh.material.uniforms.uOpacity, {
      value: 0,
      duration: 0.35,
      ease: "power2.out",
      overwrite: "auto",
    });

    var bySel = function (sel) {
      return self.detailEl.querySelector(sel);
    };
    bySel("[data-detail-index]").textContent =
      work.index + " — " + work.index + " / " + String(this.works.length).padStart(3, "0");
    bySel("[data-detail-title]").textContent = work.title;
    bySel("[data-detail-tag]").textContent = work.tag;
    bySel("[data-detail-text]").textContent = work.desc;
    bySel("[data-detail-meta]").textContent =
      [work.place, work.year].filter(Boolean).join("  •  ");
    this.detailImg.alt = work.title;

    var fast = sizedUrl(work.src, this.isMobile);
    this.detailImg.src = fast;

    gsap.to(this, { motionScale: 0, duration: 0.7, ease: "power3.out", overwrite: "auto" });

    this.playOpenTimeline(rect, fast);

    var hiRes = detailUrl(work.src);
    var upgrade = new Image();
    upgrade.onload = function () {

      if (self.detailOpen && self.detailIndex === idx && !self.closingDetail) {
        self.detailImg.src = hiRes;
      }
    };
    upgrade.src = hiRes;
  };

  CipherOrbit.prototype.playOpenTimeline = function (rect, src) {
    var self = this;
    if (this.detailTl) this.detailTl.kill();

    this.detailEl.setAttribute("aria-hidden", "false");
    this.detailShown = true;

    var flyer = this.detailFlyer;
    var card = this.detailCard;
    flyer.src = src;

    gsap.set(this.detailEl, { visibility: "visible" });
    gsap.set(this.detailBackdrop, { opacity: 0 });
    gsap.set(card, { autoAlpha: 0, scale: 0.975, transformOrigin: "50% 50%" });
    gsap.set(this.detailLines, { y: 34, autoAlpha: 0, filter: "blur(8px)" });
    gsap.set(this.detailImg, { scale: 1.12, transformOrigin: "50% 50%" });
    var slot = this.detailMedia.getBoundingClientRect();

    gsap.set(flyer, {
      left: slot.left,
      top: slot.top,
      width: slot.width,
      height: slot.height,
      x: rect.left - slot.left,
      y: rect.top - slot.top,
      scaleX: rect.width / slot.width,
      scaleY: rect.height / slot.height,
      transformOrigin: "0 0",
      autoAlpha: 1,
      force3D: true,
    });

    var tl = gsap.timeline({
      defaults: { ease: "expo.out" },
      onComplete: function () {
        self.detailAnimating = false;
        self.detailTl = null;
        var closeBtn = self.detailEl.querySelector("[data-detail-close]");
        if (closeBtn) closeBtn.focus({ preventScroll: true });
      },
    });
    this.detailTl = tl;

    if (this.reducedMotion) tl.timeScale(100);

    tl.to(this.detailBackdrop, { opacity: 1, duration: 0.55, ease: "power2.out" }, 0)
      .to(
        this.canvas,
        {
          opacity: 0.15,
          scale: 0.99,
          transformOrigin: "50% 50%",
          duration: 0.9,
          ease: "power3.out",
          force3D: true,
        },
        0
      )
      .to(this.infosEl, { autoAlpha: 0, duration: 0.35, ease: "power2.out" }, 0)

      .to(
        flyer,
        {
          x: 0,
          y: 0,
          scaleX: 1,
          scaleY: 1,
          duration: 1.15,
          ease: FLIGHT_EASE,
        },
        0.05
      )

      .to(card, { autoAlpha: 1, scale: 1, duration: 0.55, ease: "power3.out" }, "-=0.55")
      .add(function () {
        gsap.set(flyer, { autoAlpha: 0 });
        gsap.fromTo(
          self.detailImg,
          { scale: 1.12 },
          { scale: 1, duration: 1.6, ease: "expo.out", force3D: true, overwrite: "auto" }
        );
      })

      .to(
        this.detailLines,
        { y: 0, autoAlpha: 1, filter: "blur(0px)", duration: 0.9, stagger: 0.08 },
        "-=0.35"
      );
  };

  CipherOrbit.prototype.closeDetail = function () {
    if (!this.detailOpen && !this.detailAnimating) return;
    if (this.closingDetail) return;

    if (!this.detailShown) {
      if (this.detailTl) {
        this.detailTl.kill();
        this.detailTl = null;
      }
      gsap.set(this.detailEl, { visibility: "hidden" });
      gsap.set(this, { motionScale: 1 });
      if (this.detailMesh) {
        gsap.set(this.detailMesh.material.uniforms.uOpacity, { value: 1 });
        this.detailMesh = null;
      }
      this.detailEl.setAttribute("aria-hidden", "true");
      this.detailOpen = false;
      this.detailAnimating = false;
      this.detailIndex = -1;
      return;
    }
    this.closingDetail = true;
    if (this.detailTl) {
      this.detailTl.kill();
      this.detailTl = null;
    }
    var self = this;
    var mesh = this.meshes[this.detailIndex];
    var flyer = this.detailFlyer;

    var landing = function () {
      var t = mesh && self.thumbRect(mesh);
      if (t) return t;
      var w = window.innerWidth;
      var h = window.innerHeight;
      return { left: w / 2 - 150, top: h / 2 - 100, width: 300, height: 200 };
    };

    var slotBox = function () {
      return self.detailMedia.getBoundingClientRect();
    };

    var tl = gsap.timeline({
      defaults: { ease: "expo.out" },
      onComplete: function () {
        gsap.set(self.detailEl, { visibility: "hidden" });
        gsap.set(flyer, { autoAlpha: 0 });
        self.detailEl.setAttribute("aria-hidden", "true");
        self.detailOpen = false;
        self.detailAnimating = false;
        self.closingDetail = false;
        self.detailShown = false;
        self.detailIndex = -1;
        self.detailMesh = null;
        self.detailTl = null;

        gsap.to(self, { motionScale: 1, duration: 1, ease: "power2.inOut", overwrite: "auto" });
      },
    });
    this.detailTl = tl;

    if (this.reducedMotion) tl.timeScale(100);

    tl.to(
        this.detailLines,
        { y: 16, autoAlpha: 0, filter: "blur(6px)", duration: 0.35, stagger: 0.035, ease: "power2.in" },
        0
      )
      .to(
        this.detailCard,
        { autoAlpha: 0, scale: 0.985, transformOrigin: "50% 50%", duration: 0.42, ease: "power2.in" },
        0.05
      )
      .add(function () {

        var s = slotBox();
        gsap.set(flyer, {
          left: s.left,
          top: s.top,
          width: s.width,
          height: s.height,
          x: 0,
          y: 0,
          scaleX: 1,
          scaleY: 1,
          transformOrigin: "0 0",
          autoAlpha: 1,
          force3D: true,
        });
      }, 0.05)
      .to(this.detailBackdrop, { opacity: 0, duration: 0.55, ease: "power2.inOut" }, 0.08)
      .to(
        flyer,
        {

          x: function () {
            var t = landing();
            return t.left - slotBox().left;
          },
          y: function () {
            var t = landing();
            return t.top - slotBox().top;
          },
          scaleX: function () {
            var t = landing();
            return t.width / slotBox().width;
          },
          scaleY: function () {
            var t = landing();
            return t.height / slotBox().height;
          },
          duration: 1.05,
          ease: FLIGHT_EASE,
        },
        0.08
      )

      .add(function () {
        if (self.detailMesh) {
          gsap.to(self.detailMesh.material.uniforms.uOpacity, {
            value: 1,
            duration: 0.5,
            ease: "power2.out",
            overwrite: "auto",
          });
        }
      }, 0.8)
      .to(
        this.canvas,
        { opacity: 1, scale: 1, duration: 0.7, ease: "power3.out", force3D: true },
        0.3
      )
      .to(this.infosEl, { autoAlpha: 1, duration: 0.5, ease: "power2.out" }, 0.35);

  };

  CipherOrbit.prototype.triggerScramble = function (idx) {
    var self = this;
    this.scrambleTimers.forEach(clearTimeout);
    this.scrambleTimers = [];
    var toIndex = idx >= 0 ? this.works[idx].index : INDEX_BASE;
    var toLabel = idx >= 0 ? this.works[idx].title : LABEL_BASE;
    var fromIndex = this.indexEl ? this.indexEl.textContent : "";
    var fromLabel = this.labelEl ? this.labelEl.textContent : "";
    if (this.indexEl) {
      this.scrambleTimers = this.scrambleTimers.concat(
        scramble(fromIndex, toIndex, function (t) {
          self.indexEl.textContent = t;
        })
      );
    }
    if (this.labelEl) {
      this.scrambleTimers = this.scrambleTimers.concat(
        scramble(fromLabel, toLabel, function (t) {
          self.labelEl.textContent = t;
        })
      );
    }
  };

  CipherOrbit.prototype.applyFocus = function (hoveredMesh) {
    var self = this;
    var hit = hoveredMesh
      ? this.meshes.findIndex(function (m) {
          return m === hoveredMesh;
        })
      : -1;
    if (hit === this.lastHovered) return;
    this.lastHovered = hit;
    this.triggerScramble(hit);
    this.canvas.style.cursor = hoveredMesh ? "pointer" : "";
    document.body.style.cursor = hoveredMesh ? "pointer" : "default";

    this.meshes.forEach(function (mesh) {
      var u = mesh.material.uniforms;
      var isHover = hoveredMesh && mesh === hoveredMesh;
      var isDim = hoveredMesh && mesh !== hoveredMesh;
      gsap.to(u.uBlack, {
        value: isDim ? 0.5 : 0,
        duration: isDim ? 0.4 : 0.6,
        ease: isDim ? "power3.out" : "power1.out",
        overwrite: true,
      });
      gsap.to(mesh.scale, {
        x: isHover ? 1.4 : 1,
        y: isHover ? 1.4 : 1,
        duration: isHover ? 1.2 : 0.7,
        ease: "expo.out",
        overwrite: true,
      });
      if (isHover) {
        self.zCounter += 0.0000005;
        mesh.parent.position.z = self.zCounter;
      }
      gsap.to(u.uZoom, {
        value: isHover ? 1 : 1.15,
        duration: isHover ? 1.2 : 0.7,
        ease: "expo.out",
        overwrite: true,
      });
      gsap.to(u.uGrayscale, {
        value: isDim ? 1 : 0,
        duration: isDim ? 0.4 : 0.6,
        ease: isDim ? "power3.out" : "power1.out",
        overwrite: true,
      });
    });
  };

  CipherOrbit.prototype.resize = function () {
    var wasMobile = this.isMobile;
    this.isMobile = window.innerWidth <= 768;
    this.camera.aspect = window.innerWidth / window.innerHeight;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    if (wasMobile !== this.isMobile) {
      this.rx = this.isMobile ? RX_MOB : RX_DESK;
      this.ry = this.isMobile ? RY_MOB : RY_DESK;
      this.tilt = this.isMobile ? TILT_MOB : TILT_DESK;
      this.planeH = this.isMobile ? PLANE_H_MOB : PLANE_H_DESK;
      this.baseSpeed = this.reducedMotion ? 0 : BASE_SPEED;
      this.lut = this.buildLUT();
      var self = this;
      this.meshes.forEach(function (mesh) {
        var tex = mesh.material.uniforms.uTexture.value;
        var img = (tex && tex.image) || {};
        var w = img.videoWidth || img.width || 16;
        var h = img.videoHeight || img.height || 9;
        var aspect = w / h || 16 / 9;
        mesh.geometry.dispose();
        mesh.geometry = new THREE.PlaneGeometry(self.planeH * aspect, self.planeH);
      });
    }
  };

  CipherOrbit.prototype.render = function () {
    requestAnimationFrame(this._render);
    var dt = Math.min(this.clock.getDelta(), 0.05);
    var elapsed = this.clock.getElapsedTime();

    this.scrollBonus *= 0.6;

    if (!this.reducedMotion) {
      this.time -= (this.speed * this.direction + this.scrollBonus) * dt * this.motionScale;
    }

    this.raycaster.setFromCamera(this.mouse, this.camera);
    var hitObj = null;
    if (!this.introPlaying && !this.detailOpen) {
      var hits = this.raycaster.intersectObjects(this.meshes);
      if (hits.length > 0) hitObj = hits[0].object;
    }

    for (var i = 0; i < this.groups.length; i++) {
      var g = this.groups[i];
      var p = this.ellipsePoint(g.userData.timeOffset + this.time);
      g.position.x = p.x;
      g.position.y = p.y;
      var u = g.children[0].material.uniforms;
      u.uTime.value = elapsed;
      var tex = u.uTexture.value;
      if (tex && tex.isVideoTexture) tex.needsUpdate = true;
    }

    if (!this.detailOpen) this.applyFocus(hitObj);
    this.renderer.render(this.scene, this.camera);
  };

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