Repository: exadel-inc/esl
Pull request #2880 (2025-01-23 15:14:51)

fix(esl-carousel): fix initial rendering layout shift to eliminate CLS degradation
Closes: #2879


Patch:
diff --git a/package-lock.json b/package-lock.json
index 4b6e52fb5..18fc448e6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,7 +41,7 @@
         "jest-environment-jsdom": "^29.7.0",
         "kleur": "^4.1.5",
         "less": "^4.2.2",
-        "lint-staged": "^15.4.2",
+        "lint-staged": "^15.4.1",
         "postcss": "^8.5.1",
         "postcss-less": "^6.0.0",
         "prettier": "^3.4.2",
@@ -64,7 +64,7 @@
         "@types/puppeteer": "^7.0.4",
         "jest-puppeteer": "^11.0.0",
         "pixelmatch": "^5.3.0",
-        "puppeteer": "^24.1.1",
+        "puppeteer": "^24.1.0",
         "rimraf": "^6.0.1",
         "sharp": "^0.33.5",
         "stucumber": "^0.19.0"
@@ -6039,9 +6039,9 @@
       }
     },
     "node_modules/chromium-bidi": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-1.1.0.tgz",
-      "integrity": "sha512-HislCEczCuamWm3+55Lig9XKmMF13K+BGKum9rwtDAzgUAHT4h5jNwhDmD4U20VoVUG8ujnv9UZ89qiIf5uF8w==",
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.12.0.tgz",
+      "integrity": "sha512-xzXveJmX826GGq1MeE5okD8XxaDT8172CXByhFJ687eY65rbjOIebdbUuQh+jXKaNyGKI14Veb3KjLLmSueaxA==",
       "license": "Apache-2.0",
       "dependencies": {
         "mitt": "3.0.1",
@@ -13825,22 +13825,22 @@
       }
     },
     "node_modules/lint-staged": {
-      "version": "15.4.2",
-      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.4.2.tgz",
-      "integrity": "sha512-gCqzB/Li281uZJgReNci+oXXqUEdrFAQAzTE/LwoxxiEuP41vozNe4BATS+4ehdqkWn+Z6bGc3EDcBja3npBVw==",
+      "version": "15.4.1",
+      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.4.1.tgz",
+      "integrity": "sha512-P8yJuVRyLrm5KxCtFx+gjI5Bil+wO7wnTl7C3bXhvtTaAFGirzeB24++D0wGoUwxrUKecNiehemgCob9YL39NA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "chalk": "^5.4.1",
-        "commander": "^13.1.0",
-        "debug": "^4.4.0",
-        "execa": "^8.0.1",
-        "lilconfig": "^3.1.3",
-        "listr2": "^8.2.5",
-        "micromatch": "^4.0.8",
-        "pidtree": "^0.6.0",
-        "string-argv": "^0.3.2",
-        "yaml": "^2.7.0"
+        "chalk": "~5.4.1",
+        "commander": "~12.1.0",
+        "debug": "~4.4.0",
+        "execa": "~8.0.1",
+        "lilconfig": "~3.1.3",
+        "listr2": "~8.2.5",
+        "micromatch": "~4.0.8",
+        "pidtree": "~0.6.0",
+        "string-argv": "~0.3.2",
+        "yaml": "~2.6.1"
       },
       "bin": {
         "lint-staged": "bin/lint-staged.js"
@@ -13853,9 +13853,9 @@
       }
     },
     "node_modules/lint-staged/node_modules/commander": {
-      "version": "13.1.0",
-      "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
-      "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
+      "version": "12.1.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+      "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -19422,17 +19422,17 @@
       }
     },
     "node_modules/puppeteer": {
-      "version": "24.1.1",
-      "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.1.1.tgz",
-      "integrity": "sha512-fuhceZ5HZuDXVuaMIRxUuDHfCJLmK0pXh8FlzVQ0/+OApStevxZhU5kAVeYFOEqeCF5OoAyZjcWbdQK27xW/9A==",
+      "version": "24.1.0",
+      "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.1.0.tgz",
+      "integrity": "sha512-F+3yKILaosLToT7amR7LIkTKkKMR0EGQPjFBch+MtgS8vRPS+4cPnLJuXDVTfCj2NqfrCnShtOr7yD+9dEgHRQ==",
       "hasInstallScript": true,
       "license": "Apache-2.0",
       "dependencies": {
         "@puppeteer/browsers": "2.7.0",
-        "chromium-bidi": "1.1.0",
+        "chromium-bidi": "0.12.0",
         "cosmiconfig": "^9.0.0",
         "devtools-protocol": "0.0.1380148",
-        "puppeteer-core": "24.1.1",
+        "puppeteer-core": "24.1.0",
         "typed-query-selector": "^2.12.0"
       },
       "bin": {
@@ -19443,13 +19443,13 @@
       }
     },
     "node_modules/puppeteer-core": {
-      "version": "24.1.1",
-      "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.1.1.tgz",
-      "integrity": "sha512-7FF3gq6bpIsbq3I8mfbodXh3DCzXagoz3l2eGv1cXooYU4g0P4mcHQVHuBD4iSZPXNg8WjzlP5kmRwK9UvwF0A==",
+      "version": "24.1.0",
+      "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.1.0.tgz",
+      "integrity": "sha512-ReefWoQgqdyl67uWEBy/TMZ4mAB7hP0JB5HIxSE8B1ot/4ningX1gmzHCOSNfMbTiS/VJHCvaZAe3oJTXph7yw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@puppeteer/browsers": "2.7.0",
-        "chromium-bidi": "1.1.0",
+        "chromium-bidi": "0.11.0",
         "debug": "^4.4.0",
         "devtools-protocol": "0.0.1380148",
         "typed-query-selector": "^2.12.0",
@@ -19459,6 +19459,28 @@
         "node": ">=18"
       }
     },
+    "node_modules/puppeteer-core/node_modules/chromium-bidi": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz",
+      "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "mitt": "3.0.1",
+        "zod": "3.23.8"
+      },
+      "peerDependencies": {
+        "devtools-protocol": "*"
+      }
+    },
+    "node_modules/puppeteer-core/node_modules/zod": {
+      "version": "3.23.8",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
+      "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
     "node_modules/pure-rand": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@@ -23723,9 +23745,9 @@
       "license": "ISC"
     },
     "node_modules/yaml": {
-      "version": "2.7.0",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
-      "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz",
+      "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==",
       "dev": true,
       "license": "ISC",
       "bin": {
diff --git a/package.json b/package.json
index a1b1dcc16..1ced7d1e2 100644
--- a/package.json
+++ b/package.json
@@ -117,7 +117,7 @@
     "jest-environment-jsdom": "^29.7.0",
     "kleur": "^4.1.5",
     "less": "^4.2.2",
-    "lint-staged": "^15.4.2",
+    "lint-staged": "^15.4.1",
     "postcss": "^8.5.1",
     "postcss-less": "^6.0.0",
     "prettier": "^3.4.2",
diff --git a/site/views/dev/carousel-cls.njk b/site/views/dev/carousel-cls.njk
new file mode 100644
index 000000000..d836a51c9
--- /dev/null
+++ b/site/views/dev/carousel-cls.njk
@@ -0,0 +1,89 @@
+---
+layout: empty
+title: Carousel + Video BG (CLS)
+name: Carousel + Video BG (CLS)
+tags: [dev]
+scrollbar: true
+aside:
+  components:
+  - esl-media
+  - esl-carousel
+---
+
+{% import 'lorem.njk' as lorem %}
+
+<section>
+  <h2>Marquee Carousel</h2>
+
+  <div id="demo" class="esl-carousel-nav-container">
+    <button class="esl-carousel-arrow prev hide-xs"
+            esl-carousel-nav="group:prev"><span class="sr-only">Previous Slide</span></button>
+
+    <esl-carousel class="demo-carousel"
+                  esl-carousel-touch
+                  esl-carousel-keyboard
+                  tabindex="0"
+                  media="all"
+                  count="1"
+                  loop="true">
+      <ul esl-carousel-slides>
+        <li class="demo-slide" esl-carousel-slide active>
+          <div class="demo-marquee dark" style="height: 400px">
+            <esl-media class="bg-content esl-media-bg esl-animate-fade"
+                       ready-class="in"
+                       media-type="video"
+                       media-src="{{ '/assets/media/video.mp4' | url }}"
+                       fill-mode="cover"
+                       play-in-viewport
+                       lazy
+                       autoplay muted loop playsinline>
+            </esl-media>
+            <div class="demo-content">
+              <p class="h3">Slide 1</p>
+              {{ lorem.paragraphs(1) }}
+            </div>
+          </div>
+        </li>
+        <li class="demo-slide" esl-carousel-slide>
+          <div class="demo-marquee dark" style="height: 400px">
+            <esl-media class="bg-content esl-media-bg esl-animate-fade"
+                       ready-class="in"
+                       media-type="video"
+                       media-src="{{ '/assets/media/video.mp4' | url }}"
+                       fill-mode="cover"
+                       play-in-viewport
+                       lazy
+                       autoplay muted loop playsinline>
+            </esl-media>
+            <div class="demo-content">
+              <p class="h3">Slide 2</p>
+              {{ lorem.paragraphs(1) }}
+            </div>
+          </div>
+        </li>
+        <li class="demo-slide" esl-carousel-slide>
+          <div class="demo-marquee dark" style="height: 400px">
+            <esl-media class="bg-content esl-media-bg esl-animate-fade"
+                       ready-class="in"
+                       media-type="video"
+                       media-src="{{ '/assets/media/video.mp4' | url }}"
+                       fill-mode="cover"
+                       play-in-viewport
+                       lazy
+                       autoplay muted loop playsinline>
+            </esl-media>
+            <div class="demo-content">
+              <p class="h3">Slide 3</p>
+              {{ lorem.paragraphs(1) }}
+            </div>
+          </div>
+        </li>
+      </ul>
+    </esl-carousel>
+
+    <button class="esl-carousel-arrow next hide-xs"
+            esl-carousel-nav="group:next"><span class="sr-only">Next Slide</span></button>
+
+    <esl-carousel-dots class="carousel-dots-wrapper" tabindex="0"></esl-carousel-dots>
+  </div>
+</section>
diff --git a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.less b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.less
index ac8693c13..f44302fc5 100644
--- a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.less
+++ b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.less
@@ -8,9 +8,9 @@
   }
 
   &.esl-carousel-horizontal [esl-carousel-slide] {
-    width: var(--esl-slide-size);
+    width: var(--esl-slide-size, --esl-slide-initial-size, 100%);
   }
   &.esl-carousel-vertical [esl-carousel-slide] {
-    height: var(--esl-slide-size);
+    height: var(--esl-slide-size, --esl-slide-initial-size, 100%);
   }
 }
diff --git a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts
index 00919ecd5..770be1f8e 100644
--- a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts
+++ b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts
@@ -43,13 +43,20 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
    */
   public override onBind(): void {
     this.currentIndex = this.normalizeIndex(Math.max(0, this.$carousel.activeIndex));
-    this.redraw();
+    this.redraw(true);
   }
 
-  public override redraw(): void {
+  public override redraw(initial = false): void {
     this.resize();
+    // Calculate initial offset based on current rendered state (available only on an initial render)
+    const fallbackOffset = initial ? this.getOffset(this.getReserveCount()) : 0;
+
     this.reorder();
     this.setActive(this.currentIndex);
+
+    // Set initial offset based on pre-calculation
+    initial && this.setTransformOffset(-fallbackOffset);
+    // Update offset according to main algorithm (fix edge cases if the fallback offset is not correct)
     this.setTransformOffset(-this.getOffset(this.currentIndex));
   }
 
diff --git a/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.less b/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.less
index 44d94230f..4fbd42b84 100644
--- a/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.less
+++ b/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.less
@@ -18,11 +18,11 @@
 
   /* stylelint-disable-next-line */
   &.esl-carousel-horizontal :is([esl-carousel-slide], [esl-carousel-fake-slide]) {
-    width: var(--esl-slide-size);
+    width: var(--esl-slide-size, --esl-slide-initial-size, 100%);
   }
 
   /* stylelint-disable-next-line */
   &.esl-carousel-vertical :is([esl-carousel-slide], [esl-carousel-fake-slide]) {
-    height: var(--esl-slide-size);
+    height: var(--esl-slide-size, --esl-slide-initial-size, 100%);
   }
 }


Test patch:
diff --git a/e2e/package.json b/e2e/package.json
index 7ef229f5b..1bad7ea6e 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -19,7 +19,7 @@
     "@types/puppeteer": "^7.0.4",
     "jest-puppeteer": "^11.0.0",
     "pixelmatch": "^5.3.0",
-    "puppeteer": "^24.1.1",
+    "puppeteer": "^24.1.0",
     "rimraf": "^6.0.1",
     "sharp": "^0.33.5",
     "stucumber": "^0.19.0"