# WebGL Carousel

> A WebGL image carousel with shader wipes and synced editorial text — cinematic hero galleries for product and brand stories.

**Definition:** WebGL Carousel 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-05
- Canonical page: https://html.bosconvertpro.com/collection/webgl-carousel
- Full-page preview: https://html.bosconvertpro.com/collection/webgl-carousel/full
- Live demo: https://html.bosconvertpro.com/demos/webgl-carousel/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/webgl-carousel/index.md
- License: https://html.bosconvertpro.com/terms

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

## FAQ
### What is WebGL Carousel?

WebGL Carousel is a sliders UI animation from collections.html. A WebGL image carousel with shader wipes and synced editorial text — cinematic hero galleries for product and brand stories.

### What is WebGL Carousel built with?

WebGL Carousel 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 WebGL Carousel in my project?

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

### Is WebGL Carousel free to use?

Yes. The WebGL Carousel 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>WebGL Carousel</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=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=Syne:wght@500;600;700&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>

  <body>
    <div class="carousel" id="carousel" data-carousel>
      <div class="carousel__viewport" data-carousel-viewport></div>
      <div class="carousel__scrim" aria-hidden="true"></div>

      <div class="carousel__content">
        <div class="carousel__slides" data-carousel-slides aria-live="polite">
          <article class="carousel__slide" data-carousel-slide data-active>
            <span class="carousel__eyebrow font-body" data-slide-eyebrow>01 — Night Series</span>
            <h2 class="carousel__title font-display" data-slide-title>
              Midnight<br />Velocity
            </h2>
            <p class="carousel__subtitle font-body" data-slide-subtitle>
              Black sports silhouette under city lights
            </p>
          </article>

          <article class="carousel__slide" data-carousel-slide>
            <span class="carousel__eyebrow font-body" data-slide-eyebrow>02 — Precision</span>
            <h2 class="carousel__title font-display" data-slide-title>
              Pure<br />Precision
            </h2>
            <p class="carousel__subtitle font-body" data-slide-subtitle>
              Engineered curves, zero compromise
            </p>
          </article>

          <article class="carousel__slide" data-carousel-slide>
            <span class="carousel__eyebrow font-body" data-slide-eyebrow>03 — Electric</span>
            <h2 class="carousel__title font-display" data-slide-title>
              Electric<br />Horizon
            </h2>
            <p class="carousel__subtitle font-body" data-slide-subtitle>
              Silent power for the open road
            </p>
          </article>

          <article class="carousel__slide" data-carousel-slide>
            <span class="carousel__eyebrow font-body" data-slide-eyebrow>04 — Icon</span>
            <h2 class="carousel__title font-display" data-slide-title>
              Scarlet<br />Thunder
            </h2>
            <p class="carousel__subtitle font-body" data-slide-subtitle>
              Classic red. Unforgettable presence.
            </p>
          </article>
        </div>

        <div class="carousel__footer">
          <div class="carousel__meta font-body" aria-hidden="true">
            <span class="carousel__index" data-slide-index>
              <!-- Digit reels injected by JS for odometer animation -->
            </span>
            <span class="carousel__index-divider"></span>
            <span class="carousel__total" data-slide-total>04</span>
          </div>

          <nav class="carousel__controls" data-carousel-controls aria-label="Carousel navigation">
            <button
              type="button"
              class="carousel__control"
              data-direction="-1"
              aria-label="Previous slide"
            >
              <span class="carousel__control-mask" aria-hidden="true">
                <span class="carousel__control-track">
                  <svg class="carousel__control-icon" viewBox="0 0 24 24" focusable="false">
                    <path d="M15 6L9 12L15 18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
                  </svg>
                  <svg class="carousel__control-icon" viewBox="0 0 24 24" focusable="false">
                    <path d="M15 6L9 12L15 18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
                  </svg>
                </span>
              </span>
            </button>
            <button
              type="button"
              class="carousel__control"
              data-direction="1"
              aria-label="Next slide"
            >
              <span class="carousel__control-mask" aria-hidden="true">
                <span class="carousel__control-track">
                  <svg class="carousel__control-icon" viewBox="0 0 24 24" focusable="false">
                    <path d="M9 6L15 12L9 18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
                  </svg>
                  <svg class="carousel__control-icon" viewBox="0 0 24 24" focusable="false">
                    <path d="M9 6L15 12L9 18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
                  </svg>
                </span>
              </span>
            </button>
          </nav>
        </div>
      </div>
    </div>

    <script type="x-shader/x-fragment" data-fragment-shader>
      precision highp float;

      uniform vec2 uScreenSize;
      uniform float uProgress;
      uniform sampler2D uTexture0;
      uniform vec2 uTexture0Size;
      uniform sampler2D uTexture1;
      uniform vec2 uTexture1Size;
      uniform float uRotationDirection;

      varying vec2 vUv;

      #define PI 3.14159265359

      float circleMask(in vec2 st, in float radius, in float blur) {
        return 1.0 - smoothstep(
          radius - (radius * blur),
          radius + (radius * blur),
          dot(st, st) * 4.0
        );
      }

      mat2 rotate2d(float angle) {
        return mat2(
          cos(angle), -sin(angle),
          sin(angle), cos(angle)
        );
      }

      // CSS background-size: cover equivalent for texture sampling
      vec2 coverTextureUv(vec2 st, vec2 textureSize) {
        vec2 screen = uScreenSize;
        vec2 image = textureSize;
        float screenRatio = screen.x / screen.y;
        float imageRatio = image.x / image.y;

        vec2 fittedSize = screenRatio < imageRatio
          ? vec2(image.x * screen.y / image.y, screen.y)
          : vec2(screen.x, image.y * screen.x / image.x);

        vec2 offset = (screenRatio < imageRatio
          ? vec2((fittedSize.x - screen.x) / 2.0, 0.0)
          : vec2(0.0, (fittedSize.y - screen.y) / 2.0)) / fittedSize;

        return st * screen / fittedSize + offset;
      }

      void main() {
        vec2 st = vUv;

        // Centered, aspect-corrected UVs for circular masks
        vec2 centeredSt = vUv - 0.5;
        centeredSt.x *= uScreenSize.x / uScreenSize.y;

        vec2 texture0Uv = coverTextureUv(st, uTexture0Size);
        vec2 texture1Uv = coverTextureUv(st, uTexture1Size);

        float scale;
        float rotation;
        float layerProgress;

        // --- Background layer ---
        scale = 1.0 - smoothstep(0.45, 0.9, uProgress) * 0.1;
        vec2 bgUvFrom = (texture0Uv - 0.5) * scale + 0.5;

        scale = 0.9 + smoothstep(0.3, 1.0, uProgress) * 0.1;
        vec2 bgUvTo = (texture1Uv - 0.5) * scale + 0.5;

        layerProgress = smoothstep(1.0, 0.65, uProgress);
        vec3 backgroundFrom = texture2D(uTexture0, bgUvFrom).rgb;
        vec3 backgroundTo = texture2D(uTexture1, bgUvTo).rgb;
        vec3 background = mix(backgroundTo, backgroundFrom, layerProgress);

        // --- Large circle layer ---
        scale = 1.0 - smoothstep(0.3, 1.0, uProgress) * 0.5;
        vec2 largeUvFrom = (texture0Uv - 0.5) * scale + 0.5;

        scale = 0.2 + smoothstep(0.4, 0.8, uProgress) * 0.8;
        vec2 largeUvTo = (texture1Uv - 0.5) * scale + 0.5;

        rotation = PI * smoothstep(0.25, 0.95, uProgress) * uRotationDirection;
        largeUvFrom = (largeUvFrom - 0.5) * rotate2d(rotation) + 0.5;
        largeUvTo = (largeUvTo - 0.5) * rotate2d(PI + rotation) + 0.5;

        float largeMask = circleMask(centeredSt, 2.4, 0.01);
        layerProgress = smoothstep(0.9, 0.5, uProgress);

        vec3 largeFrom = texture2D(uTexture0, largeUvFrom).rgb;
        vec3 largeTo = texture2D(uTexture1, largeUvTo).rgb;
        vec3 largeCircle = mix(largeTo, largeFrom, layerProgress) * largeMask;

        background *= 1.0 - largeMask;

        // --- Medium circle layer ---
        scale = 1.0 - smoothstep(0.2, 0.95, uProgress) * 0.6;
        vec2 mediumUvFrom = (texture0Uv - 0.5) * scale + 0.5;

        scale = 0.2 + smoothstep(0.2, 0.95, uProgress) * 0.8;
        vec2 mediumUvTo = (texture1Uv - 0.5) * scale + 0.5;

        rotation = PI * smoothstep(0.2, 0.9, uProgress) * uRotationDirection;
        mediumUvFrom = (mediumUvFrom - 0.5) * rotate2d(rotation) + 0.5;
        mediumUvTo = (mediumUvTo - 0.5) * rotate2d(PI + rotation) + 0.5;

        float mediumMask = circleMask(centeredSt, 1.0, 0.01);
        layerProgress = smoothstep(0.85, 0.4, uProgress);

        vec3 mediumFrom = texture2D(uTexture0, mediumUvFrom).rgb;
        vec3 mediumTo = texture2D(uTexture1, mediumUvTo).rgb;
        vec3 mediumCircle = mix(mediumTo, mediumFrom, layerProgress) * mediumMask;

        largeCircle *= 1.0 - mediumMask;

        // --- Small circle layer ---
        scale = 1.0 - smoothstep(0.1, 0.9, uProgress) * 0.85;
        vec2 smallUvFrom = (texture0Uv - 0.5) * scale + 0.5;

        scale = 0.15 + smoothstep(0.1, 0.9, uProgress) * 0.85;
        vec2 smallUvTo = (texture1Uv - 0.5) * scale + 0.5;

        rotation = PI * smoothstep(0.15, 0.85, uProgress) * uRotationDirection;
        smallUvFrom = (smallUvFrom - 0.5) * rotate2d(rotation) + 0.5;
        smallUvTo = (smallUvTo - 0.5) * rotate2d(PI + rotation) + 0.5;

        float smallMask = circleMask(centeredSt, 0.2, 0.01);
        layerProgress = smoothstep(0.8, 0.3, uProgress);

        vec3 smallFrom = texture2D(uTexture0, smallUvFrom).rgb;
        vec3 smallTo = texture2D(uTexture1, smallUvTo).rgb;
        vec3 smallCircle = mix(smallTo, smallFrom, layerProgress) * smallMask;

        mediumCircle *= 1.0 - smallMask;

        vec3 color = background + largeCircle + mediumCircle + smallCircle;
        gl_FragColor = vec4(color, 1.0);
      }
    </script>

    <script type="x-shader/x-vertex" data-vertex-shader>
      attribute vec2 uv;
      attribute vec2 position;

      varying vec2 vUv;

      void main() {
        gl_Position = vec4(position, 0.0, 1.0);
        vUv = uv;
      }
    </script>

    <script type="module" src="./script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
/* -------------------------------------------------------------------------- */
/* Reset & base                                                                */
/* -------------------------------------------------------------------------- */

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

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
}

body {
  overflow: hidden;
  background-color: #0a0a0b;
  color: #fff;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* -------------------------------------------------------------------------- */
/* Typography utilities                                                        */
/* -------------------------------------------------------------------------- */

.font-display {
  font-family: "Syne", system-ui, sans-serif;
}

.font-body {
  font-family: "DM Sans", system-ui, sans-serif;
}

/* -------------------------------------------------------------------------- */
/* Carousel shell                                                              */
/* -------------------------------------------------------------------------- */

.carousel {
  position: relative;
  width: 100%;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  isolation: isolate;
}

.carousel__viewport {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
}

.carousel__viewport canvas {
  display: block;
  width: 100%;
  height: 100%;
}

/* Soft gradient so bottom-left copy stays readable */
.carousel__scrim {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background:
    linear-gradient(
      180deg,
      rgba(6, 6, 8, 0.42) 0%,
      rgba(6, 6, 8, 0.08) 28%,
      rgba(6, 6, 8, 0.12) 52%,
      rgba(6, 6, 8, 0.72) 100%
    ),
    linear-gradient(
      90deg,
      rgba(6, 6, 8, 0.55) 0%,
      rgba(6, 6, 8, 0.18) 42%,
      transparent 72%
    );
}

/* -------------------------------------------------------------------------- */
/* Content shell — bottom-left editorial layout                                */
/* -------------------------------------------------------------------------- */

.carousel__content {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  padding: 0 20px 22px;
  pointer-events: none;
}

@media (min-width: 768px) {
  .carousel__content {
    padding: 0 36px 32px;
  }
}

@media (min-width: 1200px) {
  .carousel__content {
    padding: 0 56px 40px;
  }
}

.carousel__slides {
  position: relative;
  width: min(100%, 720px);
  /* Reserve space so stacked absolute slides never collapse the footer */
  min-height: clamp(190px, 32vw, 300px);
  margin-bottom: 28px;
}

@media (min-width: 768px) {
  .carousel__slides {
    margin-bottom: 36px;
  }
}

/* Always stacked — GSAP owns opacity/visibility for smooth crossfades. */
.carousel__slide {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 14px;
  width: 100%;
  margin: 0;
  color: #fff;
  text-align: left;
  pointer-events: none;
  will-change: opacity, transform;
}

.carousel__eyebrow,
.carousel__title,
.carousel__subtitle {
  will-change: opacity, transform;
}

.carousel__eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  margin: 0;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.72);
}

.carousel__eyebrow::before {
  content: "";
  width: 28px;
  height: 1px;
  background: rgba(255, 255, 255, 0.55);
}

.carousel__title {
  margin: 0;
  max-width: 12ch;
  font-size: clamp(2.6rem, 8vw, 5.75rem);
  font-weight: 600;
  line-height: 0.92;
  letter-spacing: -0.03em;
  text-wrap: balance;
}

.carousel__subtitle {
  margin: 0;
  max-width: 34ch;
  font-size: clamp(0.95rem, 1.6vw, 1.125rem);
  font-weight: 400;
  line-height: 1.55;
  letter-spacing: 0.01em;
  color: rgba(255, 255, 255, 0.78);
}

/* -------------------------------------------------------------------------- */
/* Footer row: index + controls                                                */
/* -------------------------------------------------------------------------- */

.carousel__footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  width: 100%;
  pointer-events: none;
}

.carousel__meta {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  min-height: 48px;
  font-variant-numeric: tabular-nums;
}

.carousel__index {
  display: inline-flex;
  align-items: center;
  min-width: 1.6em;
  height: 1.15em;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.08em;
  line-height: 1;
  overflow: hidden;
}

/* Odometer digit columns */
.odometer {
  display: inline-flex;
  align-items: stretch;
  height: 1.15em;
  gap: 0;
}

.odometer__digit {
  position: relative;
  display: block;
  width: 0.62em;
  height: 100%;
  overflow: hidden;
}

.odometer__track {
  display: flex;
  flex-direction: column;
  align-items: center;
  will-change: transform;
}

.odometer__num {
  display: flex;
  flex: 0 0 1.15em;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 1.15em;
  font-variant-numeric: tabular-nums;
}

.carousel__index-divider {
  width: 28px;
  height: 1px;
  background: rgba(255, 255, 255, 0.35);
}

.carousel__total {
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.08em;
  color: rgba(255, 255, 255, 0.5);
}

/* -------------------------------------------------------------------------- */
/* Navigation controls — white buttons, no card shell                          */
/* -------------------------------------------------------------------------- */

.carousel__controls {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  padding: 0;
  border: none;
  background: transparent;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  pointer-events: auto;
}

.carousel__control {
  --control-icon-size: 20px;

  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: #fff;
  color: #111;
  cursor: pointer;
  outline: none;
  overflow: hidden;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
  transition:
    transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
    box-shadow 0.35s ease;
}

@media (min-width: 768px) {
  .carousel__control {
    --control-icon-size: 22px;
    width: 54px;
    height: 54px;
  }
}

/*
 * Arrow hover: mask shows one chevron; track holds two identical icons.
 * On hover the whole track slides so the next chevron replaces the first
 * (continuous “arrow runs that way” effect).
 */
.carousel__control-mask {
  display: block;
  width: var(--control-icon-size);
  height: var(--control-icon-size);
  overflow: hidden;
}

.carousel__control-track {
  display: flex;
  flex-wrap: nowrap;
  width: calc(var(--control-icon-size) * 2);
  height: var(--control-icon-size);
  transform: translate3d(0, 0, 0);
  transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
  will-change: transform;
}

.carousel__control-icon {
  flex: 0 0 var(--control-icon-size);
  width: var(--control-icon-size);
  height: var(--control-icon-size);
  display: block;
}

/* Next: track starts offset, slides right on hover → arrows flow left into place */
.carousel__control[data-direction="1"] .carousel__control-track {
  transform: translate3d(calc(var(--control-icon-size) * -1), 0, 0);
}

.carousel__control[data-direction="1"]:hover .carousel__control-track,
.carousel__control[data-direction="1"]:focus-visible .carousel__control-track {
  transform: translate3d(0, 0, 0);
}

/* Prev: track slides left on hover → arrows flow right into place */
.carousel__control[data-direction="-1"]:hover .carousel__control-track,
.carousel__control[data-direction="-1"]:focus-visible .carousel__control-track {
  transform: translate3d(calc(var(--control-icon-size) * -1), 0, 0);
}

.carousel__control:hover {
  transform: scale(1.06);
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
}

.carousel__control:active {
  transform: scale(0.96);
  transition-duration: 0.12s;
}

.carousel__control:focus-visible {
  box-shadow:
    0 0 0 2px #0a0a0b,
    0 0 0 4px #fff,
    0 12px 28px rgba(0, 0, 0, 0.22);
}

.carousel__control:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  transform: none;
  box-shadow: none;
}

.carousel__control:disabled .carousel__control-track {
  transition: none;
}

.carousel__control[data-direction="1"]:disabled .carousel__control-track {
  transform: translate3d(calc(var(--control-icon-size) * -1), 0, 0);
}

.carousel__control[data-direction="-1"]:disabled .carousel__control-track {
  transform: translate3d(0, 0, 0);
}
````

## Source — script.js
````javascript
import {
  Renderer,
  Program,
  Mesh,
  Triangle,
  Texture,
  Vec2,
} from "https://esm.sh/ogl";
import { gsap } from "https://esm.sh/gsap";

/**
 * Unsplash car photos (CORS-friendly images.unsplash.com URLs).
 * Order must match `[data-carousel-slide]` nodes.
 */
const SLIDE_IMAGE_URLS = [
  "https://images.unsplash.com/photo-1492144534655-ae79c964c9d7?auto=format&fit=crop&w=1920&h=1280&q=80",
  "https://images.unsplash.com/photo-1503376780353-7e6692767b70?auto=format&fit=crop&w=1920&h=1280&q=80",
  "https://images.unsplash.com/photo-1617788138017-80ad40651399?auto=format&fit=crop&w=1920&h=1280&q=80",
  "https://images.unsplash.com/photo-1583121274602-3e2820c69888?auto=format&fit=crop&w=1920&h=1280&q=80",
];

const SELECTORS = {
  root: "[data-carousel]",
  viewport: "[data-carousel-viewport]",
  slides: "[data-carousel-slide]",
  controls: "[data-carousel-controls] [data-direction]",
  slideEyebrow: "[data-slide-eyebrow]",
  slideTitle: "[data-slide-title]",
  slideSubtitle: "[data-slide-subtitle]",
  slideIndex: "[data-slide-index]",
  slideTotal: "[data-slide-total]",
  vertexShader: "[data-vertex-shader]",
  fragmentShader: "[data-fragment-shader]",
};

const ANIMATION = {
  /** Full WebGL wipe length */
  transitionDuration: 1.55,
  /** Content leave */
  textOutDuration: 0.55,
  /** Content enter — overlaps mid-wipe for a continuous feel */
  textInDuration: 0.9,
  textInDelay: 0.55,
  textStagger: 0.07,
  /** Odometer reel roll */
  odometerDuration: 0.85,
  odometerStagger: 0.06,
  easeOut: "power3.out",
  easeInOut: "power2.inOut",
  easeIn: "power2.in",
  odometerEase: "power3.inOut",
};

/**
 * Two-digit vertical reel counter (odometer).
 * Rolls each digit column smoothly in the navigation direction.
 */
class OdometerCounter {
  /**
   * @param {HTMLElement} root
   * @param {{ digits?: number }} [options]
   */
  constructor(root, { digits = 2 } = {}) {
    this.root = root;
    this.digitCount = digits;
    this.value = 0;
    this.tracks = [];
    this.reelTimeline = null;

    this.mount();
    this.set(0, { animate: false });
  }

  mount() {
    this.root.textContent = "";
    this.root.classList.add("odometer");
    this.root.setAttribute("aria-hidden", "true");

    this.tracks = [];

    for (let column = 0; column < this.digitCount; column += 1) {
      const digit = document.createElement("span");
      digit.className = "odometer__digit";

      const track = document.createElement("span");
      track.className = "odometer__track";

      // Triple 0–9 strip so we can roll forward/back without a hard jump.
      for (let cycle = 0; cycle < 3; cycle += 1) {
        for (let n = 0; n <= 9; n += 1) {
          const cell = document.createElement("span");
          cell.className = "odometer__num";
          cell.textContent = String(n);
          track.appendChild(cell);
        }
      }

      digit.appendChild(track);
      this.root.appendChild(digit);
      this.tracks.push(track);
    }
  }

  /**
   * @param {number} value - Display value (1-based slide number).
   * @param {{ animate?: boolean, direction?: number }} [options]
   */
  set(value, { animate = true, direction = 1 } = {}) {
    const next = Math.max(0, Math.floor(value));
    const prev = this.value;
    const nextDigits = this.toDigits(next);
    const prevDigits = this.toDigits(prev);

    if (this.reelTimeline) {
      this.reelTimeline.kill();
      this.reelTimeline = null;
    }

    if (!animate) {
      nextDigits.forEach((digit, index) => {
        // Center strip (second 0–9 block) → slot 10 + digit
        gsap.set(this.tracks[index], { y: this.offsetForSlot(10 + digit) });
      });
      this.value = next;
      return;
    }

    this.reelTimeline = gsap.timeline({
      onComplete: () => {
        // Snap tracks back to the middle strip so future rolls stay unbounded.
        nextDigits.forEach((digit, index) => {
          gsap.set(this.tracks[index], { y: this.offsetForSlot(10 + digit) });
        });
        this.reelTimeline = null;
      },
    });

    nextDigits.forEach((digit, index) => {
      if (digit === prevDigits[index]) {
        gsap.set(this.tracks[index], { y: this.offsetForSlot(10 + digit) });
        return;
      }

      const fromSlot = 10 + prevDigits[index];
      let toSlot;

      if (direction >= 0) {
        // Roll upward (next): always move forward along the strip.
        toSlot = digit >= prevDigits[index] ? 10 + digit : 20 + digit;
      } else {
        // Roll downward (prev): always move backward along the strip.
        toSlot = digit <= prevDigits[index] ? 10 + digit : digit;
      }

      gsap.set(this.tracks[index], { y: this.offsetForSlot(fromSlot) });

      this.reelTimeline.to(
        this.tracks[index],
        {
          y: this.offsetForSlot(toSlot),
          duration: ANIMATION.odometerDuration,
          ease: ANIMATION.odometerEase,
        },
        index * ANIMATION.odometerStagger,
      );
    });

    this.value = next;
  }

  /** @param {number} value */
  toDigits(value) {
    return String(value)
      .padStart(this.digitCount, "0")
      .slice(-this.digitCount)
      .split("")
      .map((d) => Number(d));
  }

  /**
   * Em-based offset for a reel slot (matches `.odometer__num` height: 1.15em).
   * @param {number} slot
   */
  offsetForSlot(slot) {
    return `${-slot * 1.15}em`;
  }
}

/**
 * Fullscreen WebGL image carousel with circular wipe transitions (OGL + GSAP).
 */
class WebGLCarousel {
  /**
   * @param {HTMLElement} root - Root carousel element.
   */
  constructor(root) {
    this.root = root;
    this.viewport = root.querySelector(SELECTORS.viewport);
    this.slideElements = [...root.querySelectorAll(SELECTORS.slides)];
    this.controlButtons = [...root.querySelectorAll(SELECTORS.controls)];
    this.slideIndexLabel = root.querySelector(SELECTORS.slideIndex);
    this.slideTotalLabel = root.querySelector(SELECTORS.slideTotal);
    this.odometer = null;

    this.activeIndex = 0;
    this.incomingIndex = 0;
    this.isTransitioning = false;
    this.transitionTimeline = null;

    this.textures = [];
    this.renderer = null;
    this.gl = null;
    this.program = null;
    this.mesh = null;

    this.handleResize = this.handleResize.bind(this);
    this.handleControlClick = this.handleControlClick.bind(this);
    this.renderFrame = this.renderFrame.bind(this);
  }

  init() {
    this.createRenderer();
    this.createPlaceholderTextures();
    this.createFullscreenMesh();
    this.loadSlideTextures();
    this.prepareSlides();
    this.initOdometer();
    this.syncSlideChrome();
    this.bindEvents();
    this.handleResize();
    gsap.ticker.add(this.renderFrame);
  }

  initOdometer() {
    if (!this.slideIndexLabel) return;
    this.odometer = new OdometerCounter(this.slideIndexLabel, { digits: 2 });
  }

  /** Ensure inactive slides start hidden without layout jumps. */
  prepareSlides() {
    this.slideElements.forEach((slide, index) => {
      const isActive = index === this.activeIndex;
      gsap.set(slide, {
        autoAlpha: isActive ? 1 : 0,
        y: 0,
      });

      if (isActive) {
        slide.setAttribute("data-active", "");
      } else {
        slide.removeAttribute("data-active");
      }
    });
  }

  /** Formats 0-based index as a zero-padded counter label (01, 02, …). */
  formatSlideNumber(index) {
    return String(index + 1).padStart(2, "0");
  }

  /** 1-based display number for the odometer. */
  displayNumberForIndex(index) {
    return index + 1;
  }

  syncSlideChrome() {
    if (this.odometer) {
      this.odometer.set(this.displayNumberForIndex(this.activeIndex), {
        animate: false,
      });
    }
    if (this.slideTotalLabel) {
      this.slideTotalLabel.textContent = String(this.slideElements.length).padStart(2, "0");
    }
  }

  getSlideCopyElements(slide) {
    return {
      eyebrow: slide.querySelector(SELECTORS.slideEyebrow),
      title: slide.querySelector(SELECTORS.slideTitle),
      subtitle: slide.querySelector(SELECTORS.slideSubtitle),
    };
  }

  destroy() {
    gsap.ticker.remove(this.renderFrame);
    this.unbindEvents();
  }

  /* ------------------------------------------------------------------------ */
  /* Setup                                                                     */
  /* ------------------------------------------------------------------------ */

  createRenderer() {
    this.renderer = new Renderer({
      dpr: Math.min(window.devicePixelRatio || 1, 2),
      alpha: false,
      depth: false,
    });
    this.gl = this.renderer.gl;
    this.gl.clearColor(0.06, 0.06, 0.07, 1);
    this.viewport.appendChild(this.gl.canvas);
  }

  createPlaceholderTextures() {
    // 1×1 placeholders avoid 0-size UV math before photos finish loading
    this.textures = SLIDE_IMAGE_URLS.map(() => {
      const texture = new Texture(this.gl, {
        generateMipmaps: false,
        wrapS: this.gl.CLAMP_TO_EDGE,
        wrapT: this.gl.CLAMP_TO_EDGE,
        minFilter: this.gl.LINEAR,
        magFilter: this.gl.LINEAR,
      });
      texture.width = 1;
      texture.height = 1;
      return texture;
    });
  }

  /**
   * Load slide images from URLs into OGL textures.
   * Remote hosts need crossOrigin + needsUpdate so WebGL can sample them.
   */
  loadSlideTextures() {
    SLIDE_IMAGE_URLS.forEach((url, index) => {
      this.loadTextureFromUrl(url, index);
    });
  }

  /**
   * @param {string} url
   * @param {number} index
   */
  loadTextureFromUrl(url, index) {
    const image = new Image();
    image.decoding = "async";

    // Required for WebGL texture upload from another origin (Unsplash, CDN, etc.)
    if (/^https?:\/\//i.test(url)) {
      image.crossOrigin = "anonymous";
    }

    const onReady = () => {
      this.applyImageToTexture(index, image);
    };

    image.onload = () => {
      if (typeof image.decode === "function") {
        image.decode().then(onReady).catch(onReady);
      } else {
        onReady();
      }
    };

    image.onerror = () => {
      console.error(`[WebGLCarousel] Failed to load image: ${url}`);
    };

    image.src = url;
  }

  /**
   * @param {number} index
   * @param {HTMLImageElement} image
   */
  applyImageToTexture(index, image) {
    const texture = this.textures[index];
    texture.image = image;
    texture.width = image.naturalWidth || image.width || 1;
    texture.height = image.naturalHeight || image.height || 1;
    // Required: empty placeholders were already uploaded once.
    texture.needsUpdate = true;

    if (index === this.activeIndex && this.program) {
      this.program.uniforms.uTexture0.value = texture;
    }
    if (index === this.incomingIndex && this.program) {
      this.program.uniforms.uTexture1.value = texture;
    }
  }

  createFullscreenMesh() {
    const geometry = new Triangle(this.gl);
    const vertex = document.querySelector(SELECTORS.vertexShader).textContent.trim();
    const fragment = document.querySelector(SELECTORS.fragmentShader).textContent.trim();

    this.program = new Program(this.gl, {
      vertex,
      fragment,
      uniforms: {
        uScreenSize: { value: new Vec2(1, 1) },
        uProgress: { value: 0 },
        uTexture0: { value: this.textures[0] },
        uTexture0Size: { value: new Vec2(1, 1) },
        uTexture1: { value: this.textures[1] },
        uTexture1Size: { value: new Vec2(1, 1) },
        uRotationDirection: { value: 1 },
      },
    });

    this.mesh = new Mesh(this.gl, {
      geometry,
      program: this.program,
    });
  }

  /* ------------------------------------------------------------------------ */
  /* Events                                                                    */
  /* ------------------------------------------------------------------------ */

  bindEvents() {
    window.addEventListener("resize", this.handleResize, { passive: true });
    this.controlButtons.forEach((button) => {
      button.addEventListener("click", this.handleControlClick);
    });
  }

  unbindEvents() {
    window.removeEventListener("resize", this.handleResize);
    this.controlButtons.forEach((button) => {
      button.removeEventListener("click", this.handleControlClick);
    });
  }

  handleResize() {
    const width = window.innerWidth;
    const height = window.innerHeight;

    this.renderer.setSize(width, height);
    this.program.uniforms.uScreenSize.value.set(width, height);
  }

  handleControlClick(event) {
    if (this.isTransitioning) return;

    const direction = Number(event.currentTarget.dataset.direction);
    if (!direction) return;

    this.goToSlide(this.wrapIndex(this.activeIndex + direction), direction);
  }

  /* ------------------------------------------------------------------------ */
  /* Navigation                                                                */
  /* ------------------------------------------------------------------------ */

  /**
   * @param {number} nextIndex - Target slide index.
   * @param {number} direction - Rotation sign: -1 previous, 1 next.
   */
  goToSlide(nextIndex, direction) {
    if (nextIndex === this.activeIndex || this.isTransitioning) return;

    if (this.transitionTimeline) {
      this.transitionTimeline.kill();
      this.transitionTimeline = null;
    }

    this.incomingIndex = nextIndex;

    const outgoingSlide = this.slideElements[this.activeIndex];
    const incomingSlide = this.slideElements[this.incomingIndex];
    const outgoing = this.getSlideCopyElements(outgoingSlide);
    const incoming = this.getSlideCopyElements(incomingSlide);
    const outgoingLines = [outgoing.eyebrow, outgoing.title, outgoing.subtitle].filter(Boolean);
    const incomingLines = [incoming.eyebrow, incoming.title, incoming.subtitle].filter(Boolean);

    // Directional micro-motion: next rises, previous settles downward
    const outY = direction > 0 ? -14 : 14;
    const inY = direction > 0 ? 22 : -22;

    outgoingSlide.classList.add("is-leaving");
    incomingSlide.classList.add("is-entering");
    incomingSlide.setAttribute("data-active", "");

    // Prepare incoming copy from below/above, fully transparent
    gsap.set(incomingSlide, { autoAlpha: 1, y: 0 });
    gsap.set(incomingLines, { autoAlpha: 0, y: inY });
    gsap.set(outgoingSlide, { autoAlpha: 1, y: 0 });

    this.transitionTimeline = gsap.timeline({
      defaults: { overwrite: "auto" },
      onStart: () => {
        this.isTransitioning = true;
        this.program.uniforms.uTexture1.value = this.textures[this.incomingIndex];
        this.program.uniforms.uRotationDirection.value = direction;
      },
      onComplete: () => {
        outgoingSlide.removeAttribute("data-active");
        outgoingSlide.classList.remove("is-leaving");
        incomingSlide.classList.remove("is-entering");

        gsap.set(outgoingSlide, { autoAlpha: 0, y: 0 });
        gsap.set(outgoingLines, { clearProps: "opacity,visibility,transform,y" });
        gsap.set(incomingLines, { clearProps: "opacity,visibility,transform,y" });
        gsap.set(incomingSlide, { autoAlpha: 1, y: 0 });

        this.activeIndex = this.incomingIndex;
        this.program.uniforms.uTexture0.value = this.textures[this.activeIndex];
        this.program.uniforms.uProgress.value = 0;
        this.syncSlideChrome();
        this.isTransitioning = false;
        this.transitionTimeline = null;
      },
    });

    // 1) Soft line-by-line exit, overlapping the wipe start
    this.transitionTimeline.to(
      outgoingLines,
      {
        autoAlpha: 0,
        y: outY,
        duration: ANIMATION.textOutDuration,
        stagger: { each: 0.05, from: "start" },
        ease: ANIMATION.easeInOut,
      },
      0,
    );

    // 2) WebGL circular wipe (linear progress for shader)
    this.transitionTimeline.to(
      this.program.uniforms.uProgress,
      {
        value: 1,
        duration: ANIMATION.transitionDuration,
        ease: "none",
      },
      0.08,
    );

    // 3) Odometer roll — smooth directional digit change
    if (this.odometer) {
      this.transitionTimeline.call(
        () => {
          this.odometer.set(this.displayNumberForIndex(this.incomingIndex), {
            animate: true,
            direction,
          });
        },
        null,
        0.18,
      );
    }

    // 4) Incoming lines ease in while wipe is still running — seamless handoff
    this.transitionTimeline.to(
      incomingLines,
      {
        autoAlpha: 1,
        y: 0,
        duration: ANIMATION.textInDuration,
        stagger: { each: ANIMATION.textStagger, from: "start" },
        ease: ANIMATION.easeOut,
      },
      ANIMATION.textInDelay,
    );

    // 5) Fully hide outgoing shell after its lines are gone
    this.transitionTimeline.set(
      outgoingSlide,
      { autoAlpha: 0 },
      ANIMATION.textOutDuration + 0.12,
    );
  }

  wrapIndex(index) {
    const total = this.textures.length;
    return ((index % total) + total) % total;
  }

  /* ------------------------------------------------------------------------ */
  /* Render loop                                                               */
  /* ------------------------------------------------------------------------ */

  syncTextureSizeUniforms() {
    const current = this.textures[this.activeIndex];
    const incoming = this.textures[this.incomingIndex];

    // Guard against 0×0 before textures resolve (prevents NaN in cover UV math)
    this.program.uniforms.uTexture0Size.value.set(
      Math.max(1, current.width || 1),
      Math.max(1, current.height || 1),
    );
    this.program.uniforms.uTexture1Size.value.set(
      Math.max(1, incoming.width || 1),
      Math.max(1, incoming.height || 1),
    );
  }

  renderFrame() {
    this.syncTextureSizeUniforms();
    this.renderer.render({ scene: this.mesh });
  }
}

/* -------------------------------------------------------------------------- */
/* Bootstrap                                                                   */
/* -------------------------------------------------------------------------- */

const carouselRoot = document.querySelector(SELECTORS.root);

if (carouselRoot) {
  const carousel = new WebGLCarousel(carouselRoot);
  carousel.init();
}
````
