Repository: exadel-inc/esl
Pull request #1690 (2023-05-30 22:32:51)

[🚩esl-carousel]: early draft


Patch:
diff --git a/package.json b/package.json
index b66ec49e0..c9a2f8ce5 100644
--- a/package.json
+++ b/package.json
@@ -41,9 +41,13 @@
     "modules/lib.js",
     "src/modules/lib.ts",
     "modules/esl-media/providers/**/*.js",
-    "modules/esl-share/actions/**/*.js",
     "src/modules/esl-media/providers/**/*.ts",
+    "modules/esl-share/actions/**/*.js",
     "src/modules/esl-share/actions/**/*.ts",
+    "modules/esl-carousel/core.js",
+    "src/modules/esl-carousel/core.ts",
+    "modules/esl-carousel/renderers/**/*.js",
+    "src/modules/esl-carousel/renderers/**/*.ts",
     "polyfills/**/*.js",
     "src/polyfills/**/*.ts",
     "pages/src/**/*.ts"
diff --git a/pages/src/common/card.less b/pages/src/common/card.less
new file mode 100644
index 000000000..573083931
--- /dev/null
+++ b/pages/src/common/card.less
@@ -0,0 +1,15 @@
+.card {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  flex-direction: column;
+  background: #fff;
+  box-shadow: 1px 1px 4px 2px rgba(0, 0, 0, .3);
+
+  &-image {
+    flex: 0 0 auto;
+  }
+  &-content {
+    flex: 1 1 auto;
+  }
+}
diff --git a/pages/src/common/helpers.less b/pages/src/common/helpers.less
index a57aed2a7..ef44e663a 100644
--- a/pages/src/common/helpers.less
+++ b/pages/src/common/helpers.less
@@ -18,7 +18,7 @@
 .hide {
   @media @xs {
     &-xs {
-      display: none;
+      display: none !important;
     }
   }
 }
diff --git a/pages/src/localdev.less b/pages/src/localdev.less
index 34cb2a09b..134d50c9b 100644
--- a/pages/src/localdev.less
+++ b/pages/src/localdev.less
@@ -9,6 +9,7 @@
 @import "./common/badge.less";
 @import "./common/buttons.less";
 @import "./common/code.less";
+@import "./common/card.less";
 @import "./common/links.less";
 @import "./common/close.less";
 @import "./common/containers.less";
@@ -37,6 +38,8 @@
 
 @import "../../src/modules/all.less";
 
+@import "./esl-carousel-demo/test-carousel.less";
+
 @import "./back-link/back-link";
 
 @import (inline) "../../node_modules/prismjs/themes/prism.css";
diff --git a/pages/src/localdev.ts b/pages/src/localdev.ts
index 39345220f..bf106f535 100644
--- a/pages/src/localdev.ts
+++ b/pages/src/localdev.ts
@@ -33,7 +33,15 @@ import {
   ESLTooltip,
   ESLAnimate,
   ESLShare,
-  ESLRelatedTarget
+  ESLRelatedTarget,
+
+  ESLCarousel,
+  ESLCarouselNavDots,
+  ESLCarouselNavMixin,
+  ESLCarouselTouchMixin,
+  ESLCarouselKeyboardMixin,
+  ESLCarouselRelateToMixin,
+  ESLCarouselAutoplayMixin
 } from '../../src/modules/all';
 
 import '../../src/modules/esl-media/providers/iframe-provider';
@@ -102,6 +110,14 @@ ESLTooltip.register();
 
 ESLAnimate.register();
 
+ESLCarousel.register();
+ESLCarouselNavDots.register();
+ESLCarouselNavMixin.register();
+ESLCarouselTouchMixin.register();
+ESLCarouselKeyboardMixin.register();
+ESLCarouselRelateToMixin.register();
+ESLCarouselAutoplayMixin.register();
+
 ESLShare.config(() => fetch('/assets/share/config.json').then((response) => response.json()));
 ESLShare.register();
 
diff --git a/pages/views/draft/carousel.njk b/pages/views/draft/carousel.njk
new file mode 100644
index 000000000..6bf927af2
--- /dev/null
+++ b/pages/views/draft/carousel.njk
@@ -0,0 +1,231 @@
+---
+layout: content
+title: Carousel
+seoTitle: Carousel UI examples based on ESL web components
+name: Carousel
+tags: [draft]
+icon: examples/carousel.svg
+---
+
+<section class="container-fluid">
+  <h2>Demo 1: </h2>
+  <p>The first slide is active (1 slide)</p>
+
+  <div class="row flex-column" esl-carousel-container>
+    <button type="button" esl-carousel-nav="group:prev"></button>
+
+    <esl-carousel esl-carousel-touch media="@-SM|@+MD" type="multi|slide" count="2|1">
+      <div data-slides-area>
+        <!-- CAROUSEL-ITEM -->
+        {% for i in range(0, 16) %}
+        <esl-carousel-slide {{ active if i == 0}}>
+          <div class="card post-text" style="background: {{ functions.randomColor() }}">
+            <p><b>{{i + 1}}</b></p>
+          </div>
+        </esl-carousel-slide>
+        {% endfor %}
+        <!-- /CAROUSEL-ITEM -->
+      </div>
+    </esl-carousel>
+
+    <button type="button" esl-carousel-nav="group:next"></button>
+
+    <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+</section>
+
+<section class="container-fluid">
+  <h2>Demo 2: </h2>
+  <p>The second slide is active (1 slide) !!!</p>
+
+  <div class="row flex-column" esl-carousel-container>
+    <button type="button" esl-carousel-nav="group:prev"></button>
+
+    <esl-carousel esl-carousel-touch media="@-SM|@+MD" count="2|1" type="multi|slide">
+      <div data-slides-area>
+        <!-- CAROUSEL-ITEM -->
+        {% for i in range(0, 16) %}
+          <esl-carousel-slide {{ active if i == 1}}>
+            <div class="card post-text" style="background: {{ functions.randomColor() }}">
+              <p><b>{{i + 1}}</b></p>
+            </div>
+          </esl-carousel-slide>
+        {% endfor %}
+        <!-- /CAROUSEL-ITEM -->
+      </div>
+    </esl-carousel>
+
+    <button type="button" esl-carousel-nav="group:next"></button>
+
+    <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+</section>
+
+<section class="container-fluid">
+  <h2>Demo 1: </h2>
+  <p>5 slides with 3 active slides to check if RAF helps to animate first one</p>
+
+  <div class="row flex-column" esl-carousel-container>
+    <button type="button" esl-carousel-nav="group:prev"></button>
+
+    <esl-carousel esl-carousel-touch count="3">
+      <div data-slides-area>
+        <!-- CAROUSEL-ITEM -->
+        {% for i in range(0, 5) %}
+          <esl-carousel-slide {{ active if i < 3}}>
+            <div class="card post-text" style="background: {{ functions.randomColor() }}">
+              <p><b>{{i + 1}}</b></p>
+            </div>
+          </esl-carousel-slide>
+        {% endfor %}
+        <!-- /CAROUSEL-ITEM -->
+      </div>
+    </esl-carousel>
+
+    <button type="button" esl-carousel-nav="group:next"></button>
+
+    <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+</section>
+
+<section class="container-fluid">
+  <h2>Demo 1: </h2>
+  <p>3 slides with 2 active slides to check if RAF helps to animate first one</p>
+
+  <div class="row flex-column" esl-carousel-container>
+
+    <button type="button" esl-carousel-nav="group:prev"></button>
+
+    <esl-carousel esl-carousel-touch count="2">
+      <div data-slides-area>
+        <!-- CAROUSEL-ITEM -->
+        {% for i in range(0, 3) %}
+          <esl-carousel-slide {{ active if i > 0}}>
+            <div class="card post-text" style="background: {{ functions.randomColor() }}">
+              <p><b>{{i + 1}}</b></p>
+            </div>
+          </esl-carousel-slide>
+        {% endfor %}
+        <!-- /CAROUSEL-ITEM -->
+      </div>
+    </esl-carousel>
+
+    <button type="button" esl-carousel-nav="group:prev"></button>
+
+    <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+</section>
+
+<section class="container-fluid">
+  <h2>Demo 2: </h2>
+  <p>11 slides with different count of active slides, no loop</p>
+
+  <div class="row flex-column" esl-carousel-container>
+    <button type="button" esl-carousel-nav="group:prev"></button>
+
+    <esl-carousel esl-carousel-touch media="all|@MD|@SM|@XS" count="5|4|3|1" loop="false">
+      <div data-slides-area>
+        <!-- CAROUSEL-ITEM -->
+        {% for i in range(0, 13) %}
+          <esl-carousel-slide>
+            <div class="card post-text" style="background: {{ functions.randomColor() }}">
+              <p><b>{{i + 1}}</b></p>
+            </div>
+          </esl-carousel-slide>
+        {% endfor %}
+        <!-- /CAROUSEL-ITEM -->
+      </div>
+    </esl-carousel>
+
+    <button type="button" esl-carousel-nav="group:next"></button>
+
+    <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+</section>
+
+<section class="container-fluid">
+  <h2>Demo 3: </h2>
+  <p>11 slides with different count of active slides (5|4|3|1 slide is active for @+LG|@MD|@SM|@XS) + no loop</p>
+
+  <div class="row flex-column" esl-carousel-container>
+    <button type="button" esl-carousel-nav="group:prev"></button>
+
+    <esl-carousel esl-carousel-touch id="second-carousel" media="all|@MD|@SM|@XS" count="5|4|3|1">
+      <div data-slides-area>
+        <!-- CAROUSEL-ITEM -->
+        {% for i in range(0, 13) %}
+          <esl-carousel-slide {{ active if i == 3}}>
+            <div class="card post-text" style="background: {{ functions.randomColor() }}">
+              <p><b>{{i + 1}}</b></p>
+            </div>
+          </esl-carousel-slide>
+        {% endfor %}
+        <!-- /CAROUSEL-ITEM -->
+      </div>
+    </esl-carousel>
+
+    <button type="button" esl-carousel-nav="group:next"></button>
+
+    <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+</section>
+
+<section class="container-fluid">
+  <h2>Linked carousel: </h2>
+
+  <div class="row flex-column" esl-carousel-container>
+    <button type="button" esl-carousel-nav="group:prev"></button>
+
+    <esl-carousel id="first-carousel"
+                  esl-carousel-touch
+                  esl-carousel-relate-to="#second-carousel"
+                  media="@XL|@LG|@MD|@SM|@XS"
+                  count="6|5|4|3|1">
+      <div data-slides-area>
+        <!-- CAROUSEL-ITEM -->
+        {% for i in range(0, 13) %}
+          <esl-carousel-slide {{ active if i < 3}}>
+            <div class="card post-text" style="background: {{ functions.randomColor() }}">
+              <p><b>{{i + 1}}</b></p>
+            </div>
+          </esl-carousel-slide>
+        {% endfor %}
+        <!-- /CAROUSEL-ITEM -->
+      </div>
+    </esl-carousel>
+
+    <button type="button" esl-carousel-nav="group:next"></button>
+
+    <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+</section>
+
+<section class="container-fluid">
+  <h2>Demo 4 (Autoscroll): </h2>
+  <a href="javascript:void 0" class="sr-only">Fake anchor</a>
+
+  <div class="row flex-column" esl-carousel-container>
+    <button type="button" esl-carousel-nav="group:prev"></button>
+
+    <esl-carousel id="second-carousel"
+                  esl-carousel-autoplay="5000"
+                  esl-carousel-relate-to="#first-carousel"
+                  count="1">
+      <div data-slides-area>
+        <!-- CAROUSEL-ITEM -->
+        {% for i in range(0, 5) %}
+          <esl-carousel-slide {{ active if i == 0}}>
+            <div class="card post-text" style="background: {{ functions.randomColor() }}">
+              <p><b>{{i + 1}}</b></p>
+            </div>
+          </esl-carousel-slide>
+        {% endfor %}
+        <!-- /CAROUSEL-ITEM -->
+      </div>
+    </esl-carousel>
+
+    <button type="button" esl-carousel-nav="group:next"></button>
+
+    <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+</section>
diff --git a/pages/views/examples/carousel.11tydata.yml b/pages/views/examples/carousel.11tydata.yml
new file mode 100644
index 000000000..532f4b4d9
--- /dev/null
+++ b/pages/views/examples/carousel.11tydata.yml
@@ -0,0 +1,59 @@
+cards:
+  - name: Slide 1
+    color: #f0f
+  - name: Slide 2
+    color: #f0f
+  - name: Slide 3
+    color: #f0f
+  - name: Slide 4
+    color: #f0f
+  - name: Slide 5
+    color: #f0f
+  - name: Slide 6
+    color: #f0f
+  - name: Slide 7
+    color: #f0f
+  - name: Slide 8
+    color: #f0f
+  - name: Slide 9
+    color: #f0f
+  - name: Slide 10
+    color: #f0f
+  - name: Slide 11
+    color: #f0f
+  - name: Slide 12
+    color: #f0f
+  - name: Slide 13
+    color: #f0f
+  - name: Slide 14
+    color: #f0f
+  - name: Slide 15
+    color: #f0f
+  - name: Slide 16
+    color: #f0f
+
+items:
+  - name: 1.Rome
+    background: '#FED6BC'
+  - name: 2.Paris
+    background: '#FFFADD'
+  - name: 3.Madrid
+    background: '#DEF7FE'
+  - name: 4.London
+    background: '#E7ECFF'
+  - name: 5.Berlin
+    background: '#C3FBD8'
+  - name: 6.Lisbon
+    background: '#FDEED9'
+  - name: 7.Barcelona
+    background: '#F6FFF8'
+  - name: 8.New York
+    background: '#B5F2EA'
+  - name: 9.Vena
+    background: '#C6D8FF'
+  - name: 10.Bern
+    background: '#F2E8C9'
+  - name: 11.Tokio
+    background: '#F5FFFA'
+  - name: 12.Warsaw
+    background: '#CCCCFF'
diff --git a/pages/views/examples/carousel.marquee.11tydata.yml b/pages/views/examples/carousel.marquee.11tydata.yml
new file mode 100644
index 000000000..25369ac82
--- /dev/null
+++ b/pages/views/examples/carousel.marquee.11tydata.yml
@@ -0,0 +1,9 @@
+slides:
+  - name: Slide 1
+  - name: Slide 2
+  - name: Slide 3
+  - name: Slide 4
+  - name: Slide 5
+  - name: Slide 6
+  - name: Slide 7
+  - name: Slide 8
diff --git a/pages/views/examples/carousel.marquee.njk b/pages/views/examples/carousel.marquee.njk
new file mode 100644
index 000000000..a265d93b6
--- /dev/null
+++ b/pages/views/examples/carousel.marquee.njk
@@ -0,0 +1,54 @@
+---
+layout: content
+title: Carousel Marquee
+seoTitle: Carousel UI examples based on ESL web components
+name: Carousel Marquee
+tags: [examples, draft]
+icon: examples/carousel.svg
+---
+
+<section class="marquee-carousel">
+  <div class="flex-column main-carousel" esl-carousel-container>
+    <esl-carousel id="main-carousel"
+                  esl-carousel-touch
+                  esl-carousel-relate-to="#nav-carousel">
+      <div data-slides-area>
+        {% for item in slides %}
+        <esl-carousel-slide>
+        <div class="img-container">
+          <esl-image mode="cover" lazy
+                        data-alt="{{ 'Carousel slide ' + loop.index }}"
+                        data-src="{{ '/assets/carousel/' + loop.index + '-sm.jpg'  | url }}"></esl-image>
+        </div>
+        </esl-carousel-slide>
+        {% endfor %}
+      </div>
+    </esl-carousel>
+
+    <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+  <div class="flex-column marquee-nav-carousel" esl-carousel-container>
+    <button class="esl-carousel-arrow inner icon-arrow-prev" esl-carousel-nav="-4"></button>
+
+    <esl-carousel id="nav-carousel"
+                  esl-carousel-touch
+                  type="@+LG"
+                  count="5">
+      <div data-slides-area>
+        {% for item in slides %}
+        <esl-carousel-slide>
+          <div class="img-container"
+               esl-carousel-nav="slide:{{ loop.index - 1 }}"
+               esl-carousel-nav-target="#main-carousel">
+            <esl-image mode="cover" lazy
+                       data-alt="{{ 'Carousel slide ' + loop.index }}"
+                       data-src="{{ '/assets/carousel/' + loop.index + '-sm.jpg'  | url }}"></esl-image>
+          </div>
+        </esl-carousel-slide>
+        {% endfor %}
+      </div>
+    </esl-carousel>
+
+    <button class="esl-carousel-arrow inner icon-arrow-next" esl-carousel-nav="+4"></button>
+  </div>
+</section>
diff --git a/pages/views/examples/carousel.njk b/pages/views/examples/carousel.njk
new file mode 100644
index 000000000..9c8e5ec2a
--- /dev/null
+++ b/pages/views/examples/carousel.njk
@@ -0,0 +1,138 @@
+---
+layout: content
+title: Carousel Example
+name: Carousel Example
+tags: [examples, draft]
+icon: examples/carousel.svg
+---
+{% import 'lorem.njk' as lorem %}
+
+<h2>Multi-slide Carousel (cards, adaptive, loop): </h2>
+<section>
+  <div esl-carousel-container>
+    <button class="esl-carousel-arrow icon-arrow-prev hide-xs"
+            esl-carousel-nav="group:prev"><span class="sr-only">Previous Slide</span></button>
+
+    <esl-carousel esl-carousel-touch
+                  esl-carousel-keyboard
+                  tabindex="0"
+                  media="@XS|@SM|@MD|@LG|@XL"
+                  loop="true"
+                  count="1|2|3|4|5">
+      <div data-slides-area>
+        {% for item in cards %}
+          <esl-carousel-slide {{ 'active' if loop.first }}>
+            <div class="card">
+              <div class="card-image img-container img-container-16-9">
+                <esl-image lazy
+                           mode="cover"
+                           data-alt="{{ 'Carousel slide ' + loop.index }}"
+                           data-src="{{ '/assets/carousel/' + loop.index + '-sm.jpg'  | url }}"></esl-image>
+              </div>
+              <div class="card-content p-3">
+                <h5>{{ item.name }}</h5>
+                {{ lorem.string(12) }}
+              </div>
+            </div>
+          </esl-carousel-slide>
+        {% endfor %}
+      </div>
+    </esl-carousel>
+
+    <button class="esl-carousel-arrow icon-arrow-next hide-xs"
+            esl-carousel-nav="group:next"><span class="sr-only">Next Slide</span></button>
+
+    <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+
+</section>
+
+<h2>Multi-slide Carousel (simplified cards, adaptive, loop): </h2>
+<section>
+  <div esl-carousel-container>
+     <button class="esl-carousel-arrow icon-arrow-prev hide-xs" esl-carousel-nav="group:prev">
+        <span class="sr-only">Previous Slide</span>
+     </button>
+
+     <esl-carousel esl-carousel-touch="@mobile"
+                   media="@XS|@SM|@MD|@LG|@XL"
+                   count="2|3|4|5|6"
+                   loop="true">
+        <div data-slides-area>
+        {% for item in items %}
+           <esl-carousel-slide {{ 'active' if loop.first }}>
+              <div class="card" style="justify-content: center; background: {{item.background}}">
+                  <h4 class="text-center mb-0">{{ item.name }}</h4>
+              </div>
+           </esl-carousel-slide>
+        {% endfor %}
+        </div>
+     </esl-carousel>
+
+     <button class="esl-carousel-arrow icon-arrow-next hide-xs" esl-carousel-nav="group:next">
+        <span class="sr-only">Next Slide</span>
+     </button>
+
+     <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+</section>
+
+<h2>Multi-slide Carousel (simplified cards, adaptive, without loop): </h2>
+<section>
+  <div esl-carousel-container>
+    <button class="esl-carousel-arrow icon-arrow-prev hide-xs" esl-carousel-nav="group:prev">
+      <span class="sr-only">Previous Slide</span>
+    </button>
+
+    <esl-carousel esl-carousel-touch="@mobile"
+                  media="@XS|@SM|@MD|@LG|@XL"
+                  count="2|3|4|5|6">
+      <div data-slides-area>
+        {% for item in items %}
+          <esl-carousel-slide {{ 'active' if loop.first }}>
+            <div class="card" style="justify-content: center; background: {{item.background}}">
+              <h4 class="text-center mb-0">{{ item.name }}</h4>
+            </div>
+          </esl-carousel-slide>
+        {% endfor %}
+      </div>
+    </esl-carousel>
+
+    <button class="esl-carousel-arrow icon-arrow-next hide-xs" esl-carousel-nav="group:next">
+      <span class="sr-only">Next Slide</span>
+    </button>
+
+    <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+</section>
+
+<h2>Single active slide demo: </h2>
+<section>
+  <div esl-carousel-container>
+     <button class="esl-carousel-arrow icon-arrow-prev hide-xs" esl-carousel-nav="group:prev">
+        <span class="sr-only">Previous Slide</span>
+     </button>
+
+     <esl-carousel esl-carousel-touch="@mobile" loop="true">
+        <div data-slides-area>
+          {% for i in range(0, 4) -%}
+           <esl-carousel-slide>
+              <div class="card">
+                  <div class="img-container img-container-16-9">
+                      <esl-image mode="cover" lazy
+                                 data-alt="Alt Text Test"
+                                 data-src="{{ '/assets/carousel/' + loop.index + '-sm.jpg' | url }}"></esl-image>
+                  </div>
+              </div>
+           </esl-carousel-slide>
+          {%- endfor %}
+        </div>
+     </esl-carousel>
+
+     <button class="esl-carousel-arrow icon-arrow-next hide-xs" esl-carousel-nav="group:next">
+        <span class="sr-only">Next Slide</span>
+     </button>
+
+     <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
+  </div>
+</section>
diff --git a/src/modules/all.less b/src/modules/all.less
index 05fab970e..245287e41 100644
--- a/src/modules/all.less
+++ b/src/modules/all.less
@@ -25,3 +25,5 @@
 @import "./esl-animate/core.less";
 
 @import "./esl-share/core.less";
+
+@import "./esl-carousel/all.less";
diff --git a/src/modules/all.ts b/src/modules/all.ts
index a6dde9383..b16299161 100644
--- a/src/modules/all.ts
+++ b/src/modules/all.ts
@@ -45,3 +45,6 @@ export * from './esl-related-target/core';
 
 // Share
 export * from './esl-share/core';
+
+// Carousel
+export * from './esl-carousel/core';
diff --git a/src/modules/esl-carousel/README.md b/src/modules/esl-carousel/README.md
new file mode 100644
index 000000000..05a5e0590
--- /dev/null
+++ b/src/modules/esl-carousel/README.md
@@ -0,0 +1,33 @@
+# [ESL](https://exadel-inc.github.io/esl/) Carousel
+
+Version: *1.0.0-beta*.
+
+Authors: *Julia Murashko*, *Alexey Stsefanovich (ala'n)*.
+
+***Important Notice: the component is under beta version, it is tested and ready to use but be aware of its potential critical API changes.***
+
+<a name="intro"></a>
+
+ESLCarousel - a slideshow component for cycling through slides.
+
+ESLCarouselSlide - a component that provides content for ESLCarousel.
+
+The elements are interrelated and don't make sense on their own. This is because once the elements are added to the DOM, they establish the link between ESLCarousel and each ESLCarouselSlide.
+
+### ESLCarousel Attributes | Properties:
+
+- `config` (ESLCarouselConfig) - [MediaQuery](../esl-media-query/README.md) to define behavior of ESLCarousel.
+
+### ESLCarouselConfig (Configuration API)
+
+- `type` (string) - a carousel rendering renderer
+  - `slide` - default, one slide is active
+  - `multi` - more than one slide can be active
+- `count` (number) - a total number of slides
+- `loop` (boolean) - defines whether the carousel is in a loop (false by default)
+- `cls` (string) - a CSS class or classes to mark the carousel element (empty by default)
+  (supports ESL extended class definition syntax, [CSSClassUtil](../esl-utils/dom/class.ts))
+
+### ESLCarouselSlide Attributes | Properties:
+
+- `active` (boolean) - an active state marker
diff --git a/src/modules/esl-carousel/all.less b/src/modules/esl-carousel/all.less
new file mode 100644
index 000000000..719eaa947
--- /dev/null
+++ b/src/modules/esl-carousel/all.less
@@ -0,0 +1,4 @@
+@import "./core";
+
+@import "./plugin/nav/esl-carousel.nav.arrows.less";
+@import "./plugin/nav/esl-carousel.nav.dots.less";
diff --git a/src/modules/esl-carousel/core.less b/src/modules/esl-carousel/core.less
new file mode 100644
index 000000000..f67f28c89
--- /dev/null
+++ b/src/modules/esl-carousel/core.less
@@ -0,0 +1,4 @@
+@import "./core/esl-carousel";
+
+// Renderer Default
+@import "./renderers/multi/esl-multi-carousel.less";
diff --git a/src/modules/esl-carousel/core.ts b/src/modules/esl-carousel/core.ts
new file mode 100644
index 000000000..fa4218e37
--- /dev/null
+++ b/src/modules/esl-carousel/core.ts
@@ -0,0 +1,23 @@
+// Core
+export * from './core/esl-carousel.events';
+export {ESLCarousel} from './core/esl-carousel';
+export {ESLCarouselSlide} from './core/esl-carousel.slide';
+
+// Navigation
+export * from './plugin/nav/esl-carousel.nav.mixin';
+export * from './plugin/nav/esl-carousel.nav.dots';
+
+// Touch support
+export * from './plugin/touch/esl-carousel.touch.mixin';
+
+// Keyboard support
+export * from './plugin/keyboard/esl-carousel.keyboard.mixin';
+
+// Autoplay
+export * from './plugin/autoplay/esl-carousel.autoplay.mixin';
+
+// Link Utility
+export * from './plugin/relation/esl-carousel.relation.mixin';
+
+// Renderer Default
+import './renderers/multi/esl-multi-carousel';
diff --git a/src/modules/esl-carousel/core/esl-carousel.events.ts b/src/modules/esl-carousel/core/esl-carousel.events.ts
new file mode 100644
index 000000000..48f8492e2
--- /dev/null
+++ b/src/modules/esl-carousel/core/esl-carousel.events.ts
@@ -0,0 +1,82 @@
+import type {ESLCarousel} from './esl-carousel';
+import type {ESLCarouselSlide} from './esl-carousel.slide';
+import type {ESLCarouselDirection, ESLCarouselStaticState} from './nav/esl-carousel.nav.types';
+
+/** {@link ESLCarouselSlideEvent} init object */
+interface ESLCarouselSlideEventInit {
+  /** Current slide index */
+  current: number;
+  /** Related slide index (target on pre-event, current on post-event) */
+  related: number;
+  /** Direction of slide animation */
+  direction: ESLCarouselDirection;
+  /** Auxiliary request attribute that represents object that initiate slide change */
+  activator?: any;
+}
+
+/** {@link ESLCarousel} event that represents slide change event */
+export class ESLCarouselSlideEvent extends Event implements ESLCarouselSlideEventInit {
+  /** {@link ESLCarouselSlideEvent} event type dispatched before slide change (pre-event) */
+  public static readonly BEFORE = 'esl:before:slide:change';
+  /** {@link ESLCarouselSlideEvent} event type dispatched after slide change (post-event) */
+  public static readonly AFTER = 'esl:slide:change';
+
+  public override readonly target: ESLCarousel;
+  public readonly current: number;
+  public readonly related: number;
+  public readonly direction: ESLCarouselDirection;
+  public readonly activator?: any;
+
+  protected constructor(
+    type: string,
+    init: ESLCarouselSlideEventInit
+  ) {
+    super(type, {
+      bubbles: true,
+      cancelable: ESLCarouselSlideEvent.BEFORE === type,
+      composed: true
+    });
+    Object.assign(this, init);
+  }
+
+  public static create(type: 'BEFORE' | 'AFTER', init: ESLCarouselSlideEventInit): ESLCarouselSlideEvent {
+    return new ESLCarouselSlideEvent(ESLCarouselSlideEvent[type], init);
+  }
+}
+
+/** {@link ESLCarouselChangeEvent} init object */
+interface ESLCarouselChangeEventInit {
+  /** Current {@link ESLCarousel} instance config */
+  config: ESLCarouselStaticState;
+  /** Previous {@link ESLCarousel} instance config */
+  oldConfig?: ESLCarouselStaticState;
+  /** A list of {@link ESLCarouselSlide}s that have added to the current carousel instance */
+  added: ESLCarouselSlide[];
+  /** A list of {@link ESLCarouselSlide}s that have removed from the current carousel instance */
+  removed: ESLCarouselSlide[];
+}
+
+/** {@link ESLCarousel} event that represents slide configuration change */
+export class ESLCarouselChangeEvent extends Event implements ESLCarouselChangeEventInit {
+  /** {@link ESLCarouselSlideEvent} event type dispatched on carousel config changes */
+  public static readonly TYPE = 'esl:carousel:change';
+
+  public override readonly target: ESLCarousel;
+  public readonly config: ESLCarouselStaticState;
+  public readonly oldConfig?: ESLCarouselStaticState;
+  public readonly added: ESLCarouselSlide[] = [];
+  public readonly removed: ESLCarouselSlide[] = [];
+
+  protected constructor(init: ESLCarouselChangeEventInit) {
+    super(ESLCarouselChangeEvent.TYPE, {
+      bubbles: true,
+      cancelable: false,
+      composed: true
+    });
+    Object.assign(this, init);
+  }
+
+  public static create(init: ESLCarouselChangeEventInit): ESLCarouselChangeEvent {
+    return new ESLCarouselChangeEvent(init);
+  }
+}
diff --git a/src/modules/esl-carousel/core/esl-carousel.less b/src/modules/esl-carousel/core/esl-carousel.less
new file mode 100644
index 000000000..01b49b745
--- /dev/null
+++ b/src/modules/esl-carousel/core/esl-carousel.less
@@ -0,0 +1,50 @@
+// CORE styles
+esl-carousel {
+  position: relative;
+  display: block;
+  max-width: 100%;
+  // Important to ignore padding/margin hack
+  box-sizing: content-box;
+  margin: -5px;
+  padding: 5px;
+  overflow: hidden;
+
+
+  [data-slides-area] {
+    position: relative;
+    width: 100%;
+    display: flex;
+    gap: 20px;
+    z-index: 0;
+    transform: translate3d(0px, 0px, 0px);
+  }
+
+  &[dragging] {
+    cursor: grabbing;
+  }
+
+  &[dragging] [data-slides-area],
+  &[animating] [data-slides-area] {
+    user-select: none;
+    touch-action: pan-y;
+    pointer-events: none;
+  }
+
+  &-slide {
+    display: flex;
+    box-sizing: border-box;
+    margin: 0;
+    padding: 0;
+
+    position: relative;
+    left: 0;
+    z-index: 1;
+
+    min-width: 250px;
+    min-height: 150px;
+  }
+}
+
+[esl-carousel-container] {
+  position: relative;
+}
diff --git a/src/modules/esl-carousel/core/esl-carousel.renderer.ts b/src/modules/esl-carousel/core/esl-carousel.renderer.ts
new file mode 100644
index 000000000..8af521028
--- /dev/null
+++ b/src/modules/esl-carousel/core/esl-carousel.renderer.ts
@@ -0,0 +1,139 @@
+import {memoize} from '../../esl-utils/decorators';
+import {SyntheticEventTarget} from '../../esl-utils/dom';
+import {ESLCarouselSlideEvent} from './esl-carousel.events';
+
+import type {ESLCarousel, ESLCarouselActionParams} from './esl-carousel';
+import type {ESLCarouselDirection} from './nav/esl-carousel.nav.types';
+
+export abstract class ESLCarouselRenderer {
+  public static is: string;
+
+  protected readonly carousel: ESLCarousel;
+
+  constructor(carousel: ESLCarousel) {
+    this.carousel = carousel; // TODO: unsafe while lifecycle is not clear
+  }
+
+  /** @returns renderer type name */
+  public get type(): string {
+    return (this.constructor as typeof ESLCarouselRenderer).is;
+  }
+
+  /** @returns count of carousel slides */
+  public get size(): number {
+    return this.carousel.size;
+  }
+  /** @returns count of visible carousel slides */
+  public get count(): number {
+    return this.carousel.config.count;
+  }
+  /** @returns if the carousel is in a loop mode */
+  public get loop(): boolean {
+    return this.carousel.config.loop;
+  }
+
+  /** @returns {@link ESLCarousel} `$slideArea` */
+  public get $area(): HTMLElement {
+    return this.carousel.$slidesArea;
+  }
+
+  public bind(): void {
+    const type = this.constructor as typeof ESLCarouselRenderer;
+    this.carousel.classList.add(`${type.is}-carousel`);
+
+    this.onBind();
+  }
+  public unbind(): void {
+    const type = this.constructor as typeof ESLCarouselRenderer;
+    this.carousel.classList.remove(`${type.is}-carousel`);
+
+    this.onUnbind();
+  }
+
+  /** Processes binding of defined renderer to the carousel {@link ESLCarousel}. */
+  public onBind(): void {}
+  /** Processes unbinding of defined renderer from the carousel {@link ESLCarousel}. */
+  public onUnbind(): void {}
+  /** Processes drawing of the carousel {@link ESLCarousel}. */
+  public redraw(): void {}
+  /** Process slide change process */
+  public async navigate(index: number, direction: ESLCarouselDirection, {activator}: ESLCarouselActionParams): Promise<void> {
+    const {activeIndex, activeIndexes} = this.carousel;
+
+    if (activeIndex === index && activeIndexes.length === this.count) return;
+    if (!this.carousel.dispatchEvent(ESLCarouselSlideEvent.create('BEFORE', {
+      direction,
+      activator,
+      current: activeIndex,
+      related: index
+    }))) return;
+
+    try {
+      await this.onBeforeAnimate(index, direction);
+      await this.onAnimate(index, direction);
+      await this.onAfterAnimate(index, direction);
+    } catch (e: unknown) {
+      console.error(e);
+    }
+
+    this.setActive(index);
+
+    this.carousel.dispatchEvent(ESLCarouselSlideEvent.create('AFTER', {
+      direction,
+      activator,
+      current: index,
+      related: activeIndex
+    }));
+  }
+
+  /** Pre-processing animation action. */
+  public abstract onBeforeAnimate(index?: number, direction?: ESLCarouselDirection): Promise<void>;
+  /** Processes animation. */
+  public abstract onAnimate(index: number, direction: ESLCarouselDirection): Promise<void>;
+  /** Post-processing animation action. */
+  public abstract onAfterAnimate(index: number, direction: ESLCarouselDirection): Promise<void>;
+
+  /** Handles the slides transition. */
+  public abstract onMove(offset: number): void;
+  /** Ends current transition and make permanent all changes performed in the transition. */
+  public abstract commit(offset?: number): void;
+
+  /** Sets active slides from passed index **/
+  public setActive(from: number): void {
+    const count = Math.min(this.count, this.size);
+    this.carousel.$slides.forEach((el) => el.active = false);
+    for (let i = 0; i < count; i++) {
+      this.carousel.slideAt(from + i).active = true;
+    }
+  }
+
+  // Register API
+  @memoize()
+  public static get registry(): ESLCarouselRendererRegistry {
+    return new ESLCarouselRendererRegistry();
+  }
+  public static register(view: ESLCarouselRendererConstructor): void {
+    ESLCarouselRenderer.registry.register(view);
+  }
+}
+
+export type ESLCarouselRendererConstructor = (new(carousel: ESLCarousel) => ESLCarouselRenderer) & typeof ESLCarouselRenderer;
+
+export class ESLCarouselRendererRegistry extends SyntheticEventTarget {
+  private store = new Map<string, ESLCarouselRendererConstructor>();
+
+  public create(name: string, carousel: ESLCarousel): ESLCarouselRenderer {
+    let Renderer = this.store.get(name);
+    if (!Renderer) [Renderer] = this.store.values(); // take first Renderer in store
+    return new Renderer(carousel);
+  }
+
+  public register(view: ESLCarouselRendererConstructor): void {
+    if (!view || !view.is) throw Error('[ESL]: CarouselRendererRegistry] incorrect registration request');
+    if (this.store.has(view.is)) throw Error(`View with name ${view.is} already defined`);
+    this.store.set(view.is, view);
+    const detail = {name: view.is, view};
+    const event = new CustomEvent('change', {detail});
+    this.dispatchEvent(event);
+  }
+}
diff --git a/src/modules/esl-carousel/core/esl-carousel.slide.ts b/src/modules/esl-carousel/core/esl-carousel.slide.ts
new file mode 100644
index 000000000..592d96829
--- /dev/null
+++ b/src/modules/esl-carousel/core/esl-carousel.slide.ts
@@ -0,0 +1,75 @@
+import {ESLBaseElement} from '../../esl-base-element/core';
+import {boolAttr, memoize} from '../../esl-utils/decorators';
+import {findNext, findPrev, findNextLooped, findPrevLooped} from '../../esl-utils/dom/traversing';
+
+import type {ESLCarousel} from './esl-carousel';
+
+/**
+ * ESLCarouselSlide component
+ * @author Julia Murashko, Alexey Stsefanovich (ala'n)
+ *
+ * ESLCarouselSlide - a component that provides content for ESLCarousel {@link ESLCarousel}
+ */
+export class ESLCarouselSlide extends ESLBaseElement {
+  /** @returns if the slide is active */
+  @boolAttr() public active: boolean;
+
+  @memoize()
+  public get $carousel(): ESLCarousel | undefined {
+    const carouselTag = this.baseTagName.replace('-slide', '');
+    return this.closest(carouselTag) as ESLCarousel | undefined;
+  }
+
+  protected override connectedCallback(): void {
+    super.connectedCallback();
+    this.$carousel?.addSlide && this.$carousel.addSlide(this);
+    this.updateA11y();
+  }
+
+  protected override disconnectedCallback(): void {
+    this.$carousel?.removeSlide && this.$carousel.removeSlide(this);
+    memoize.clear(this, '$carousel');
+    super.disconnectedCallback();
+  }
+
+  /** @returns index of the slide in the carousel. */
+  public get index(): number {
+    if (!this.parentNode) return NaN;
+    return Array.prototype.indexOf.call(this.parentNode.children, this);
+  }
+
+  /** @returns next slide sibling. */
+  public get $next(): ESLCarouselSlide {
+    return findNext(this, (this.constructor as typeof ESLCarouselSlide).is) as ESLCarouselSlide;
+  }
+  /** @returns prev slide sibling. */
+  public get $prev(): ESLCarouselSlide {
+    return findPrev(this, (this.constructor as typeof ESLCarouselSlide).is) as ESLCarouselSlide;
+  }
+
+  /** @returns next slide sibling (uses cyclic find). */
+  public get $nextCyclic(): ESLCarouselSlide {
+    return findNextLooped(this, (this.constructor as typeof ESLCarouselSlide).is) as ESLCarouselSlide;
+  }
+  /** @returns previous slide sibling (uses cyclic find). */
+  public get $prevCyclic(): ESLCarouselSlide {
+    return findPrevLooped(this, (this.constructor as typeof ESLCarouselSlide).is) as ESLCarouselSlide;
+  }
+
+  protected updateA11y(): void {
+    this.setAttribute('role', 'group');
+    if (!this.hasAttribute('aria-roledescription')) {
+      this.setAttribute('aria-roledescription', 'slide');
+    }
+    if (!this.hasAttribute('aria-label')) {
+      this.setAttribute('aria-label', `carousel item ${this.index + 1}`);
+    }
+  }
+
+  /** Creates slide element, use passed content as slide inner */
+  public static override create(content?: HTMLElement | DocumentFragment): ESLCarouselSlide {
+    const $slide = super.create() as ESLCarouselSlide;
+    if (content) $slide.appendChild(content);
+    return $slide;
+  }
+}
diff --git a/src/modules/esl-carousel/core/esl-carousel.ts b/src/modules/esl-carousel/core/esl-carousel.ts
new file mode 100644
index 000000000..47eeae274
--- /dev/null
+++ b/src/modules/esl-carousel/core/esl-carousel.ts
@@ -0,0 +1,286 @@
+import {ExportNs} from '../../esl-utils/environment/export-ns';
+import {ESLBaseElement} from '../../esl-base-element/core';
+import {attr, boolAttr, decorate, listen, memoize} from '../../esl-utils/decorators';
+
+import {microtask} from '../../esl-utils/async';
+import {parseBoolean, isEqual} from '../../esl-utils/misc';
+
+import {ESLMediaRuleList} from '../../esl-media-query/core';
+import {ESLResizeObserverTarget} from '../../esl-event-listener/core';
+
+import {normalizeIndex, toIndex, canNavigate} from './nav/esl-carousel.nav.utils';
+
+import {ESLCarouselSlide} from './esl-carousel.slide';
+import {ESLCarouselRenderer} from './esl-carousel.renderer';
+import {ESLCarouselChangeEvent} from './esl-carousel.events';
+
+import type {ESLCarouselState, ESLCarouselDirection, ESLCarouselSlideTarget, ESLCarouselStaticState} from './nav/esl-carousel.nav.types';
+
+/** {@link ESLCarousel} action params interface */
+export interface ESLCarouselActionParams {
+  /** Element requester of the change */
+  activator?: any;
+  /** Direction to move to. */
+  direction?: ESLCarouselDirection;
+  // TODO: implement
+  // noAnimation?: boolean;
+}
+
+/**
+ * ESLCarousel component
+ * @author Julia Murashko, Alexey Stsefanovich (ala'n)
+ *
+ * ESLCarousel - a slideshow component for cycling through slides {@link ESLCarouselSlide}.
+ */
+@ExportNs('Carousel')
+export class ESLCarousel extends ESLBaseElement {
+  public static override is = 'esl-carousel';
+  public static observedAttributes = ['media', 'type', 'loop', 'count'];
+
+  /** Media query pattern used for {@link ESLMediaRuleList} of `type`, `loop` and `count` (default: `all`) */
+  @attr({name: 'media', defaultValue: 'all'}) public mediaCfg: string;
+  /** Renderer type name (`multi` by default). Supports {@link ESLMediaRuleList} syntax */
+  @attr({name: 'type', defaultValue: 'multi'}) public typeCfg: string;
+  /** Marker to enable loop mode for carousel (`true` by default). Supports {@link ESLMediaRuleList} syntax */
+  @attr({name: 'loop', defaultValue: 'false'}) public loopCfg: string;
+  /** Count of slides to show on the screen (`1` by default). Supports {@link ESLMediaRuleList} syntax */
+  @attr({name: 'count', defaultValue: '1'}) public countCfg: string;
+
+  /** true if carousel is in process of animating */
+  @boolAttr({readonly: true}) public animating: boolean;
+
+  /** Renderer type {@link ESLMediaRuleList} instance */
+  @memoize()
+  public get typeRule(): ESLMediaRuleList<string> {
+    return ESLMediaRuleList.parse(this.mediaCfg, this.typeCfg);
+  }
+  /** Loop marker {@link ESLMediaRuleList} instance */
+  @memoize()
+  public get loopRule(): ESLMediaRuleList<boolean> {
+    return ESLMediaRuleList.parse(this.mediaCfg, this.loopCfg, parseBoolean);
+  }
+  /** Count of visible slides {@link ESLMediaRuleList} instance */
+  @memoize()
+  public get countRule(): ESLMediaRuleList<number> {
+    return ESLMediaRuleList.parse(this.mediaCfg, this.countCfg, parseInt);
+  }
+
+  /** Carousel instance current {@link ESLCarouselStaticState} */
+  @memoize()
+  public get config(): ESLCarouselStaticState {
+    return {
+      size: this.$slides.length,
+      count: this.countRule.value || 1,
+      loop: !!this.loopRule.value
+    };
+  }
+
+  /** Carousel instance current {@link ESLCarouselState} */
+  public get state(): ESLCarouselState {
+    return Object.assign({}, this.config, {
+      activeIndex: this.activeIndex
+    });
+  }
+
+  /** @returns currently active renderer */
+  @memoize()
+  public get renderer(): ESLCarouselRenderer {
+    const type = this.typeRule.value || 'multi';
+    const renderer = ESLCarouselRenderer.registry.create(type, this);
+    renderer && renderer.bind(); // TODO: small small - getter side effect
+    return renderer;
+  }
+
+  protected override connectedCallback(): void {
+    super.connectedCallback();
+    this.update();
+    this.updateA11y();
+  }
+
+  protected override attributeChangedCallback(attrName: string, oldVal: string, newVal: string): void {
+    if (!this.connected) return;
+    memoize.clear(this, `${attrName}Rule`);
+    this.update();
+  }
+
+  protected override disconnectedCallback(): void {
+    super.disconnectedCallback();
+    memoize.clear(this, ['$container', '$slides', '$slidesArea']);
+  }
+
+  /** Updates the config and the state that is associated with */
+  @decorate(microtask)
+  public update(): void {
+    const oldType = this.renderer.type;
+    const oldConfig = this.config;
+    const $oldSlides = this.$slides;
+
+    memoize.clear(this, ['config', '$slides']);
+    if (oldType !== this.typeRule.value) {
+      this.renderer && this.renderer.unbind();
+      memoize.clear(this, 'renderer');
+    }
+    this.updateContainer();
+    this.renderer.redraw();
+
+    const added = this.$slides.filter((slide) => !$oldSlides.includes(slide));
+    const removed = $oldSlides.filter((slide) => !this.$slides.includes(slide));
+    const config = this.config;
+
+    if (!added.length && !removed.length && isEqual(config, oldConfig)) return;
+    this.dispatchEvent(ESLCarouselChangeEvent.create({added, removed, config, oldConfig}));
+  }
+
+  public updateContainer(): void {
+    if (!this.$container) return;
+    this.$container.toggleAttribute('empty', this.size === 0);
+    this.$container.toggleAttribute('single', this.size === 1);
+    this.$container.toggleAttribute('incomplete', this.size <= this.config.count);
+  }
+
+  /** Appends slide instance to the current carousel */
+  public addSlide(slide: HTMLElement | ESLCarouselSlide): void {
+    if (!slide) return;
+    if (!(slide instanceof ESLCarouselSlide)) return this.addSlide(ESLCarouselSlide.create(slide));
+    if (slide.parentNode !== this.$slidesArea) {
+      slide.remove();
+      this.$slidesArea?.appendChild(slide);
+    }
+    this.update();
+  }
+
+  /** Remove slide instance from the current carousel */
+  public removeSlide(slide: HTMLElement | ESLCarouselSlide): void {
+    if (!slide) return;
+    if (!(slide instanceof ESLCarouselSlide)) return this.removeSlide(slide.closest(ESLCarouselSlide.is)!);
+    if (slide.parentNode === this.$slidesArea) this.$slidesArea?.removeChild(slide);
+    this.update();
+  }
+
+  protected updateA11y(): void {
+    // TODO: update a11y -> check a11y everywhere
+  }
+
+  @listen({
+    event: 'change',
+    target: ({typeRule, countRule, loopRule}: ESLCarousel) => [typeRule, countRule, loopRule]
+  })
+  protected _onRuleUpdate(): void {
+    this.update();
+  }
+
+  @listen({event: 'change', target: ESLCarouselRenderer.registry})
+  protected _onRegistryUpdate(): void {
+    this.update();
+  }
+
+  @listen({event: 'resize', target: ESLResizeObserverTarget.for})
+  protected _onResize(): void {
+    this.renderer && this.renderer.redraw();
+  }
+
+  /** @returns slides that are processed by the current carousel. */
+  @memoize()
+  public get $slides(): ESLCarouselSlide[] {
+    const els = this.$slidesArea ? [...this.$slidesArea.children] : [];
+    return els.filter((slide): slide is ESLCarouselSlide  => slide.matches(ESLCarouselSlide.is));
+  }
+
+  /** @returns carousel container */
+  @memoize()
+  public get $container(): HTMLElement | null {
+    return this.closest(`[${ESLCarousel.is}-container]`);
+  }
+
+  /** @returns slides carousel area */
+  @memoize()
+  public get $slidesArea(): HTMLElement {
+    const $provided = this.querySelector('[data-slides-area]');
+    if ($provided) return $provided as HTMLElement;
+    const $container = document.createElement('div');
+    $container.setAttribute('data-slides-area', '');
+    this.appendChild($container);
+    return $container ;
+  }
+
+  /** @returns first active slide */
+  public get $activeSlide(): ESLCarouselSlide | null {
+    const actives = this.$slides.filter((el) => el.active);
+    if (actives.length === 0) return null;
+    if (actives.length === this.$slides.length) return this.$slides[0];
+
+    for (const slide of actives) {
+      const prevIndex = normalizeIndex(slide.index - 1, this.size);
+      if (!this.$slides[prevIndex].active) return slide;
+    }
+    return this.$slides[0];
+  }
+
+  /** @returns list of active slides. */
+  public get $activeSlides(): ESLCarouselSlide[] {
+    let $slide = this.$activeSlide;
+    let i = this.size;
+    const arr: ESLCarouselSlide[] = [];
+    while ($slide?.active && i > 0) {
+      arr.push($slide);
+      $slide = $slide.$nextCyclic;
+      i--;
+    }
+    return arr;
+  }
+
+  /** @returns count of slides. */
+  public get size(): number {
+    return this.$slides.length || 0;
+  }
+
+  /** @returns index of first active slide. */
+  public get activeIndex(): number {
+    return this.$activeSlide?.index || 0;
+  }
+  /** @returns list of active slide indexes. */
+  public get activeIndexes(): number[] {
+    return this.$slides.reduce((activeIndexes: number[], el, index) => {
+      if (el.active) activeIndexes.push(index);
+      return activeIndexes;
+    }, []);
+  }
+
+  /** Goes to the target according to passed params */
+  public goTo(target: ESLCarouselSlideTarget, params: ESLCarouselActionParams = {}): void {
+    if (!this.renderer) return;
+    const {index, dir} = toIndex(target, this.state);
+    const direction = params.direction || dir || 'next';
+    this.renderer.navigate(index, direction, params);
+  }
+
+  /** @returns side by index (supports not normalized indexes) */
+  public slideAt(index: number): ESLCarouselSlide {
+    return this.$slides[normalizeIndex(index, this.$slides.length)];
+  }
+
+  /** @returns if the passed slide target can be reached */
+  public canNavigate(target: ESLCarouselSlideTarget): boolean {
+    return canNavigate(target, this.state);
+  }
+
+  /**
+   * Registers component in the {@link customElements} registry
+   * @param tagName - custom tag name to register custom element
+   */
+  public static override register(tagName?: string): void {
+    ESLCarouselSlide.register((tagName || ESLCarousel.is) + '-slide');
+    customElements.whenDefined(ESLCarouselSlide.is).then(() => super.register.call(this, tagName));
+  }
+}
+
+declare global {
+  export interface ESLCarouselNS {}
+  export interface ESLLibrary {
+    Carousel: typeof ESLCarousel & ESLCarouselNS;
+  }
+  export interface HTMLElementTagNameMap {
+    'esl-carousel': ESLCarousel;
+    'esl-carousel-slide': ESLCarouselSlide;
+  }
+}
diff --git a/src/modules/esl-carousel/core/nav/esl-carousel.nav.types.ts b/src/modules/esl-carousel/core/nav/esl-carousel.nav.types.ts
new file mode 100644
index 000000000..223fef656
--- /dev/null
+++ b/src/modules/esl-carousel/core/nav/esl-carousel.nav.types.ts
@@ -0,0 +1,32 @@
+export type ESLCarouselDirection = 'next' | 'prev';
+
+export type ESLCarouselNavIndex = number | `${number}` | `+${number}` | `-${number}` | ESLCarouselDirection;
+
+/**
+ * Supported navigation syntax:
+ *
+ * | Example    | Description          |
+ * |:---:|:---|
+ * | `1`, `2`   | direct index (short) |
+ * | `slide: 1`, `slide: 2` | direct index (short) |
+ * | `group: 1`, `group: 2` | direct group |
+ * | `+1`, `slide: +1`, `slide: +2` | per slide increment |
+ * | `-1`, `slide: -1`, `slide: -2` | per slide decrement |
+ * | `group: +1`, `group: +2` | per group increment |
+ * | `group: -1`, `group: -2` | per group decrement |
+ * | `next`, `slide: next`, `group: next` | semantic word `next` for increment |
+ * | `prev`, `slide: prev`, `group: prev` | semantic word `prev` for decrement |
+ */
+export type ESLCarouselSlideTarget = string | ESLCarouselNavIndex | `slide:${ESLCarouselNavIndex}` | `group:${ESLCarouselNavIndex}`;
+
+/** Object describing static carousel configuration */
+export type ESLCarouselStaticState = {
+  size: number;
+  count: number;
+  loop: boolean;
+};
+
+/** Object describing carousel current configuration (contains active slide data) */
+export type ESLCarouselState = ESLCarouselStaticState & {
+  activeIndex: number;
+};
diff --git a/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts b/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts
new file mode 100644
index 000000000..eeea17a01
--- /dev/null
+++ b/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts
@@ -0,0 +1,123 @@
+import type {ESLCarouselDirection, ESLCarouselNavIndex, ESLCarouselSlideTarget, ESLCarouselState} from './esl-carousel.nav.types';
+
+/** @returns bounded slide index in bounds of [0, count] range */
+export function boundIndex(value: number, max: number): number {
+  return Math.max(0, Math.min(max, value));
+}
+
+/** @returns normalized slide index in bounds of [0, count] range */
+export function normalizeIndex(index: number, size: number): number {
+  return (size + (index % size)) % size;
+}
+
+/** Gets count of slides between active and passed considering given direction. */
+export function getDistance(from: number, direction: ESLCarouselDirection, {activeIndex, size}: ESLCarouselState): number {
+  if (direction === 'prev') return normalizeIndex(activeIndex - from, size);
+  if (direction === 'next') return normalizeIndex(from - activeIndex, size);
+  return 0;
+}
+
+/** @returns closest direction to move from slide `from` to slide `to` */
+export function calcDirection(from: number, to: number, size: number): ESLCarouselDirection {
+  const abs = Math.abs(from - to) % size;
+  if (to > from) {
+    return abs > size - abs ? 'prev' : 'next';
+  } else {
+    return abs > size - abs ? 'next' : 'prev';
+  }
+}
+
+/** @returns normalized numeric index from group index */
+export function groupToIndex(group: number, count: number, size: number): number {
+  const groupCount = Math.ceil(size / count);
+  const value = normalizeIndex(group, groupCount);
+  const index = value + 1 === groupCount ? size - count : count * value;
+  return normalizeIndex(index, size);
+}
+
+/** @returns numeric group index from slide index */
+export function indexToGroup(index: number, count: number, size: number): number {
+  const value = normalizeIndex(index, size);
+  const groupCount = Math.ceil(size / count);
+  const firstGroupIndex = value + count >= size ? groupCount - 1 : Math.floor(value / count);
+  const secondGroupIndex = (firstGroupIndex + 1) % groupCount;
+  // candidate groups have common slides
+  const isGroupIntersected = size - count + 1 < value + count && value + count <= size;
+  const commonCount = isGroupIntersected ? groupCount * count - size : 0;
+  const firstGroupCount = Math.min(size, (firstGroupIndex + 1) * count) - value;
+  const secondGroupCount = count - firstGroupCount + commonCount;
+  return (firstGroupCount >= secondGroupCount) ? firstGroupIndex : secondGroupIndex;
+}
+
+export function indexToDirection(index: number, {activeIndex, size, loop}: ESLCarouselState): ESLCarouselDirection | null {
+  if (loop) return calcDirection(activeIndex, index, size);
+  if (activeIndex < index) return 'next';
+  if (activeIndex > index) return 'prev';
+  return null;
+}
+
+function splitTarget(target: string): {index: string, type: string} {
+  // Sanitize value
+  target = String(target).replace(/\s/, '');
+  // Normalize shortcuts
+  target = target.includes(':') ? target : `slide:${target}`;
+  // Split type and index part
+  const [type, index] = String(target).split(':');
+  return {type, index};
+}
+
+/** Parse index value defining its value and type (absolute|relative)*/
+function parseIndex(index: string | ESLCarouselNavIndex): {value: number, isRelative: boolean, dir?: ESLCarouselDirection} {
+  if (typeof index === 'number') return {value: index, isRelative: false};
+  index = index.trim();
+  if (index === 'next') return {value: 1, isRelative: true, dir: 'next'};
+  if (index === 'prev') return {value: -1, isRelative: true, dir: 'prev'};
+  if (index[0] === '+') return {value: +index, isRelative: true, dir: 'next'};
+  if (index[0] === '-') return {value: +index, isRelative: true, dir: 'prev'};
+  return {value: +index, isRelative: false};
+}
+
+/** @returns normalized numeric index from string with absolute or relative index */
+function resolveSlideIndex(indexStr: string | ESLCarouselNavIndex, cfg: ESLCarouselState): {index: number, dir: ESLCarouselDirection | null} {
+  const {value, isRelative, dir} = parseIndex(indexStr);
+  const target = value + (isRelative ? cfg.activeIndex : 0);
+  const index = cfg.loop ? normalizeIndex(target, cfg.size) : boundIndex(target, cfg.size - cfg.count);
+  return {index, dir: dir || indexToDirection(index, cfg)};
+}
+
+/** @returns normalized numeric index from string with absolute or relative group index */
+function resolveGroupIndex(indexStr: string | ESLCarouselNavIndex, cfg: ESLCarouselState): {index: number, dir: ESLCarouselDirection | null} {
+  const {value, isRelative, dir} = parseIndex(indexStr);
+  if (!isRelative) {
+    const index = groupToIndex(value, cfg.count, cfg.size);
+    return {index, dir: indexToDirection(index, cfg)};
+  }
+  // TODO: extend navigation boundaries
+  if (value === -1 && cfg.activeIndex < cfg.count && cfg.activeIndex > 0) {
+    return {index: 0, dir: dir || 'prev'};
+  }
+  if (value === 1 && normalizeIndex(cfg.activeIndex + cfg.count, cfg.size) > cfg.size - cfg.count) {
+    return {index: cfg.size - cfg.count, dir: dir || 'next'};
+  }
+  const index = normalizeIndex(cfg.activeIndex + value * cfg.count, cfg.size);
+  return {index, dir: dir || indexToDirection(index, cfg)};
+}
+
+/** @returns normalized index from target definition and current state */
+export function toIndex(target: ESLCarouselSlideTarget, cfg: ESLCarouselState): {index: number, dir: ESLCarouselDirection | null} {
+  if (typeof target === 'number') {
+    const index = cfg.loop ? normalizeIndex(target, cfg.size) : boundIndex(target, cfg.size - cfg.count);
+    return {index, dir: indexToDirection(index, cfg)};
+  }
+  const {type, index} = splitTarget(target);
+  if (type === 'group') return resolveGroupIndex(index, cfg);
+  if (type === 'slide') return resolveSlideIndex(index, cfg);
+  return {index: cfg.activeIndex, dir: null};
+}
+
+export function canNavigate(target: ESLCarouselSlideTarget, cfg: ESLCarouselState): boolean {
+  const {dir, index} = toIndex(target, cfg);
+  if (!cfg.loop && index > cfg.activeIndex && dir === 'prev') return false;
+  if (!cfg.loop && index < cfg.activeIndex && dir === 'next') return false;
+  return !!dir && index !== cfg.activeIndex;
+}
diff --git a/src/modules/esl-carousel/plugin/autoplay/esl-carousel.autoplay.mixin.ts b/src/modules/esl-carousel/plugin/autoplay/esl-carousel.autoplay.mixin.ts
new file mode 100644
index 000000000..b5d98c2b7
--- /dev/null
+++ b/src/modules/esl-carousel/plugin/autoplay/esl-carousel.autoplay.mixin.ts
@@ -0,0 +1,77 @@
+import {ExportNs} from '../../../esl-utils/environment/export-ns';
+import {attr, bind, listen} from '../../../esl-utils/decorators';
+
+import {ESLCarouselPlugin} from '../esl-carousel.plugin';
+import {ESLCarouselSlideEvent} from '../../core/esl-carousel.events';
+
+/**
+ * {@link ESLCarousel} auto-play (auto-advance) plugin mixin
+ * Automatically switch slides by timeout
+ *
+ * @author Alexey Stsefanovich (ala'n)
+ */
+@ExportNs('Carousel.Autoplay')
+export class ESLCarouselAutoplayMixin extends ESLCarouselPlugin {
+  public static override is = 'esl-carousel-autoplay';
+
+  /** Timeout to send next command to the host carousel */
+  @attr({defaultValue: '5000', name: ESLCarouselAutoplayMixin.is})
+  public timeout: number;
+
+  /** Navigation command to send to the host carousel. Default: 'slide:next' */
+  @attr({defaultValue: 'slide:next', name: ESLCarouselAutoplayMixin.is + '-command'})
+  public command: string;
+
+  private _timeout: number | null = null;
+
+  protected override connectedCallback(): void {
+    if (super.connectedCallback()) {
+      this.start();
+    }
+  }
+
+  protected override disconnectedCallback(): void {
+    super.disconnectedCallback();
+    this.stop();
+  }
+
+  protected override attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
+    this.start();
+  }
+
+  /** Activates the timer to send commands */
+  public start(): void {
+    this.stop();
+    this._timeout = window.setTimeout(this._onInterval, this.timeout);
+  }
+
+  /** Deactivates the timer to send commands */
+  public stop(): void {
+    if (typeof this._timeout === 'number') {
+      window.clearTimeout(this._timeout);
+    }
+  }
+
+  /** Handles next timer interval */
+  @bind
+  protected _onInterval(): void {
+    this.$host?.goTo(this.command);
+    this._timeout = window.setTimeout(this._onInterval, this.timeout);
+  }
+
+  /** Handles auxiliary events to pause/resume timer */
+  @listen(`mouseout mouseover focusin focusout ${ESLCarouselSlideEvent.AFTER}`)
+  protected _onInteract(e: Event): void {
+    if (['mouseover', 'focusin'].includes(e.type)) {
+      this.stop();
+    } else {
+      this.start();
+    }
+  }
+}
+
+declare global {
+  export interface ESLCarouselNS {
+    Autoplay: typeof ESLCarouselAutoplayMixin;
+  }
+}
diff --git a/src/modules/esl-carousel/plugin/esl-carousel.plugin.ts b/src/modules/esl-carousel/plugin/esl-carousel.plugin.ts
new file mode 100644
index 000000000..cb0a3556e
--- /dev/null
+++ b/src/modules/esl-carousel/plugin/esl-carousel.plugin.ts
@@ -0,0 +1,25 @@
+import {ESLMixinElement} from '../../esl-mixin-element/ui/esl-mixin-element';
+import {ESLCarousel} from '../core/esl-carousel';
+
+/** Base mixin plugin of {@link ESLCarousel} */
+export abstract class ESLCarouselPlugin extends ESLMixinElement {
+  /** {@link ESLCarousel} host instance */
+  public override $host: ESLCarousel;
+
+  protected override connectedCallback(): boolean | void {
+    const {$host} = this;
+    if (($host as unknown) instanceof ESLCarousel) {
+      super.connectedCallback();
+      return true;
+    } else {
+      const {is} = this.constructor as typeof ESLCarouselPlugin;
+      console.error('[ESL]: %o is not correct target for %o', $host, is);
+      this.$host.removeAttribute(is);
+      return false;
+    }
+  }
+
+  public static override register(): void {
+    ESLCarousel.registered.then(() => super.register());
+  }
+}
diff --git a/src/modules/esl-carousel/plugin/keyboard/esl-carousel.keyboard.mixin.ts b/src/modules/esl-carousel/plugin/keyboard/esl-carousel.keyboard.mixin.ts
new file mode 100644
index 000000000..116aa6b9a
--- /dev/null
+++ b/src/modules/esl-carousel/plugin/keyboard/esl-carousel.keyboard.mixin.ts
@@ -0,0 +1,35 @@
+import {ExportNs} from '../../../esl-utils/environment/export-ns';
+
+import {ESLCarouselPlugin} from '../esl-carousel.plugin';
+import {attr, listen} from '../../../esl-utils/decorators';
+import {ARROW_LEFT, ARROW_RIGHT} from '../../../esl-utils/dom/keys';
+
+/**
+ * {@link ESLCarousel} Keyboard arrow support
+ *
+ * @author Alexey Stsefanovich (ala'n)
+ */
+@ExportNs('Carousel.Keyboard')
+export class ESLCarouselKeyboardMixin extends ESLCarouselPlugin {
+  public static override is = 'esl-carousel-keyboard';
+
+  /** Prefix to request next/prev navigation */
+  @attr({defaultValue: 'slide', name: ESLCarouselKeyboardMixin.is}) public type: 'slide' | 'group';
+
+  /** Handles `keydown` event */
+  @listen('keydown')
+  protected _onKeydown(event: KeyboardEvent): void {
+    if (ARROW_LEFT === event.key) {
+      this.$host.goTo(`${this.type}:prev`);
+    }
+    if (ARROW_RIGHT === event.key) {
+      this.$host.goTo(`${this.type}:next`);
+    }
+  }
+}
+
+declare global {
+  export interface ESLCarouselNS {
+    Keyboard: typeof ESLCarouselKeyboardMixin;
+  }
+}
diff --git a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.arrows.less b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.arrows.less
new file mode 100644
index 000000000..66d2d497b
--- /dev/null
+++ b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.arrows.less
@@ -0,0 +1,61 @@
+/** Arrow navigation styles */
+[esl-carousel-container] {
+  --esl-carousel-arrow-size: 40px;
+  --esl-carousel-arrow-offset: clamp(-40px, calc(40px - 40 * 100% / 700), -25px);
+
+  .esl-carousel-arrow {
+    display: flex;
+
+    position: absolute;
+    z-index: 3;
+    top: 50%;
+    transform: translateY(-50%);
+
+    background: none;
+    border: none;
+    cursor: pointer;
+    appearance: none;
+
+    &[disabled] {
+      opacity: .25;
+    }
+  }
+
+  // Positioning
+  .esl-carousel-arrow {
+    width: var(--esl-carousel-arrow-size);
+    height: 100%;
+    left: var(--esl-carousel-arrow-offset);
+
+    padding-bottom: 30px;
+
+    &.inner {
+      left: 0;
+    }
+  }
+  esl-carousel + .esl-carousel-arrow {
+    left: auto;
+    right: var(--esl-carousel-arrow-offset);
+
+    &.inner {
+      left: auto;
+      right: 0;
+    }
+  }
+
+  .icon-arrow-next::before,
+  .icon-arrow-prev::before {
+    content: '';
+    display: block;
+    width: 100%;
+    height: 100%;
+    margin: auto;
+    opacity: .75;
+    background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='black' viewBox='5 5 40 40'%3E%3Cpolygon points='31 40 16 25 31 10 33 12 20 25 33 38'/%3E%3C/svg%3E") no-repeat center center;
+
+    filter: drop-shadow(0 0 4px #fff);
+  }
+  .icon-arrow-next::before {
+    transform: scaleX(-1);
+  }
+}
diff --git a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.dots.less b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.dots.less
new file mode 100644
index 000000000..5023b00ce
--- /dev/null
+++ b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.dots.less
@@ -0,0 +1,28 @@
+// TODO: refactor
+// DOTS navigation
+.esl-carousel-dots {
+  display: block;
+  text-align: center;
+  opacity: 1;
+  transition: opacity .5s ease-out;
+  cursor: default;
+
+  .carousel-dot {
+    display: inline-block;
+    width: 9px;
+    height: 9px;
+    margin: 0 5px;
+    vertical-align: middle;
+    background: rgba(118, 118, 118, .5);
+    border: none;
+    border-radius: 24px;
+    cursor: pointer;
+  }
+
+  .active-dot {
+    width: 11px;
+    height: 11px;
+    background: rgba(255, 255, 255, .5);
+    border: 1px solid #bababa;
+  }
+}
diff --git a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.dots.ts b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.dots.ts
new file mode 100644
index 000000000..23e57f7e1
--- /dev/null
+++ b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.dots.ts
@@ -0,0 +1,124 @@
+// TODO: rework, simplify and refactor
+import {ExportNs} from '../../../esl-utils/environment/export-ns';
+import {attr, listen, memoize} from '../../../esl-utils/decorators';
+import {ARROW_LEFT, ARROW_RIGHT} from '../../../esl-utils/dom/keys';
+import {findNextLooped, findPrevLooped} from '../../../esl-utils/dom/traversing';
+import {ESLBaseElement} from '../../../esl-base-element/core';
+import {ESLTraversingQuery} from '../../../esl-traversing-query/core';
+
+import {indexToGroup} from '../../core/nav/esl-carousel.nav.utils';
+import {ESLCarouselChangeEvent, ESLCarouselSlideEvent} from '../../core/esl-carousel.events';
+
+import type {ESLCarousel} from '../../core/esl-carousel';
+
+/**
+ * {@link ESLCarousel} Dots navigation element
+ * @author Julia Murashko
+ *
+ * Example:
+ * ```
+ * <esl-carousel-dots></esl-carousel-dots>
+ * ```
+ */
+@ExportNs('Carousel.Dots')
+export class ESLCarouselNavDots extends ESLBaseElement {
+  public static override is = 'esl-carousel-dots';
+
+  /** {@link ESLTraversingQuery} string to find {@link ESLCarousel} instance */
+  @attr({
+    defaultValue: '::parent([esl-carousel-container])::find(esl-carousel)'
+  })
+  public carousel: string;
+
+  /** @returns ESLCarousel instance; based on {@link carousel} attribute */
+  @memoize()
+  public get $carousel(): ESLCarousel | null {
+    return ESLTraversingQuery.first(this.carousel, this) as ESLCarousel;
+  }
+
+  public override async connectedCallback(): Promise<void> {
+    this.$$attr('disabled', true);
+    if (!this.$carousel) return;
+    await customElements.whenDefined(this.$carousel.tagName.toLowerCase());
+    super.connectedCallback();
+    this.rerender();
+    this.updateA11y();
+  }
+
+  public override disconnectedCallback(): void {
+    super.disconnectedCallback();
+    memoize.clear(this, '$carousel');
+  }
+
+  /** Renders dots according to the carousel state. */
+  public rerender(): void {
+    if (!this.$carousel) return;
+    const {activeIndex, count, size} = this.$carousel.state;
+    this.$$attr('disabled', size < 2);
+    let html = '';
+    const activeDot = indexToGroup(activeIndex, count, size);
+    for (let i = 0; i < Math.ceil(size / count); ++i) {
+      html += this.buildDot(i, i === activeDot);
+    }
+    this.innerHTML = html;
+  }
+
+  /** Builds content of dots. */
+  public buildDot(index: number, isActive: boolean): string {
+    return `<button role="button"
+                aria-label="Show group ${index  + 1}"
+                aria-disabled="${isActive}"
+                data-nav-target="group:${index}"
+                class="carousel-dot ${isActive ? 'active-dot' : ''}"
+                aria-current="${isActive ? 'true' : 'false'}"></button>`;
+  }
+
+  protected updateA11y(): void {
+    this.setAttribute('role', 'group');
+    if (!this.hasAttribute('aria-label')) {
+      this.setAttribute('aria-label', 'Carousel dots');
+    }
+  }
+
+  /** Handles carousel state changes */
+  @listen({
+    event: `${ESLCarouselSlideEvent.AFTER} ${ESLCarouselChangeEvent.TYPE}`,
+    target: ($el: ESLCarouselNavDots) => $el.$carousel
+  })
+  protected _onSlideChange(e: Event): void {
+    if (this.$carousel !== e.target) return;
+    this.rerender();
+  }
+
+  /** Handles `click` on the dots */
+  @listen('click')
+  protected _onClick(event: PointerEvent): void {
+    if (!this.$carousel || typeof this.$carousel.goTo !== 'function') return;
+    const $target = (event.target as Element).closest('[data-nav-target]');
+    if (!$target) return;
+    this.$carousel.goTo($target.getAttribute('data-nav-target')!);
+  }
+
+  /** Handles `keydown` event */
+  @listen('keydown')
+  protected _onKeydown(event: KeyboardEvent): void {
+    const $eventTarget: HTMLElement = event.target as HTMLElement;
+    if (ARROW_LEFT === event.key) {
+      const $nextDot = findPrevLooped($eventTarget, '.carousel-dot') as HTMLElement;
+      $nextDot.click();
+    }
+    if (ARROW_RIGHT === event.key) {
+      const $nextDot = findNextLooped($eventTarget, '.carousel-dot') as HTMLElement;
+      $nextDot.click();
+    }
+  }
+}
+
+declare global {
+  export interface ESLCarouselNS {
+    Dots: typeof ESLCarouselNavDots;
+  }
+  export interface HTMLElementTagNameMap {
+    'esl-carousel-dots': ESLCarouselNavDots;
+  }
+}
diff --git a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.mixin.ts b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.mixin.ts
new file mode 100644
index 000000000..cb1c479d4
--- /dev/null
+++ b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.mixin.ts
@@ -0,0 +1,79 @@
+import {ExportNs} from '../../../esl-utils/environment/export-ns';
+import {attr, listen, memoize} from '../../../esl-utils/decorators';
+import {ESLMixinElement} from '../../../esl-mixin-element/core';
+import {ESLTraversingQuery} from '../../../esl-traversing-query/core';
+
+import {ESLCarouselChangeEvent, ESLCarouselSlideEvent} from '../../core/esl-carousel.events';
+
+import type {ESLCarousel} from '../../core/esl-carousel';
+import type {ESLCarouselSlideTarget} from '../../core/nav/esl-carousel.nav.types';
+
+/**
+ * ESLCarousel navigation helper to define triggers for carousel navigation.
+ *
+ * Example:
+ * ```
+ * <div esl-carousel-container>
+ *  <button esl-carousel-nav="group:prev">Prev</button>
+ *  <esl-carousel>...</esl-carousel>
+ *  <button esl-carousel-nav="group:next">Next</button>
+ * </div>
+ * ```
+ */
+@ExportNs('Carousel.Nav')
+export class ESLCarouselNavMixin extends ESLMixinElement {
+  static override is = 'esl-carousel-nav';
+
+  /** {@link ESLCarouselSlideTarget} target to navigate in carousel */
+  @attr({name: ESLCarouselNavMixin.is}) public target: ESLCarouselSlideTarget;
+
+  /** {@link ESLTraversingQuery} string to find {@link ESLCarousel} instance */
+  @attr({
+    name: ESLCarouselNavMixin.is + '-target',
+    defaultValue: '::parent([esl-carousel-container])::find(esl-carousel)'
+  })
+  public carousel: string;
+
+  /** @returns ESLCarousel instance; based on {@link carousel} attribute */
+  @memoize()
+  public get $carousel(): ESLCarousel | null {
+    return ESLTraversingQuery.first(this.carousel, this.$host) as ESLCarousel;
+  }
+
+  public override async connectedCallback(): Promise<void> {
+    this.$$attr('disabled', true);
+    if (!this.$carousel) return;
+    await customElements.whenDefined(this.$carousel.tagName.toLowerCase());
+    super.connectedCallback();
+    this._onSlideChange();
+  }
+
+  public override disconnectedCallback(): void {
+    super.disconnectedCallback();
+    memoize.clear(this, '$carousel');
+  }
+
+  /** Handles carousel state changes */
+  @listen({
+    event: `${ESLCarouselSlideEvent.AFTER} ${ESLCarouselChangeEvent.TYPE}`,
+    target: ($nav: ESLCarouselNavMixin) => $nav.$carousel
+  })
+  protected _onSlideChange(): void {
+    const canNavigate = this.$carousel && this.$carousel.canNavigate(this.target);
+    this.$$attr('disabled', !canNavigate);
+  }
+
+  /** Handles nat target clicks */
+  @listen('click')
+  protected _onClick(e: PointerEvent): void {
+    if (!this.$carousel || typeof this.$carousel.goTo !== 'function') return;
+    this.$carousel.goTo(this.target);
+    e.preventDefault();
+  }
+}
+
+declare global {
+  export interface ESLCarouselNS {
+    Nav: typeof ESLCarouselNavMixin;
+  }
+}
diff --git a/src/modules/esl-carousel/plugin/relation/esl-carousel.relation.mixin.ts b/src/modules/esl-carousel/plugin/relation/esl-carousel.relation.mixin.ts
new file mode 100644
index 000000000..8bec63b47
--- /dev/null
+++ b/src/modules/esl-carousel/plugin/relation/esl-carousel.relation.mixin.ts
@@ -0,0 +1,46 @@
+import {ExportNs} from '../../../esl-utils/environment/export-ns';
+import {attr, listen, memoize} from '../../../esl-utils/decorators';
+import {ESLTraversingQuery} from '../../../esl-traversing-query/core';
+
+import {ESLCarousel} from '../../core/esl-carousel';
+import {ESLCarouselPlugin} from '../esl-carousel.plugin';
+import {ESLCarouselSlideEvent} from '../../core/esl-carousel.events';
+
+/**
+ * Slide Carousel Link plugin mixin to bind carousel positions
+ */
+@ExportNs('Carousel.RelateTo')
+export class ESLCarouselRelateToMixin extends ESLCarouselPlugin {
+  public static override is = 'esl-carousel-relate-to';
+
+  @attr({name: ESLCarouselRelateToMixin.is})
+  public target: string;
+
+  /** @returns ESLCarousel target to share state changes */
+  @memoize()
+  public get $target(): ESLCarousel | null {
+    const $target = ESLTraversingQuery.first(this.target);
+    return ($target instanceof ESLCarousel) ? $target : null;
+  }
+
+  protected override attributeChangedCallback(attrName: string, oldVal: string, newVal: string): void {
+    memoize.clear(this, '$target');
+  }
+
+  /** Handles event that fires when the carousel slides state is changed. */
+  @listen(ESLCarouselSlideEvent.AFTER)
+  protected _onSlideChange(e: ESLCarouselSlideEvent): void {
+    if (!this.$target) return;
+    if (e.activator !== this) {
+      this.$target.goTo(this.$host.activeIndex, {
+        activator: this
+      });
+    }
+  }
+}
+
+declare global {
+  export interface ESLCarouselNS {
+    RelateTo: typeof ESLCarouselRelateToMixin;
+  }
+}
diff --git a/src/modules/esl-carousel/plugin/touch/esl-carousel.touch.mixin.ts b/src/modules/esl-carousel/plugin/touch/esl-carousel.touch.mixin.ts
new file mode 100644
index 000000000..ed096b6e5
--- /dev/null
+++ b/src/modules/esl-carousel/plugin/touch/esl-carousel.touch.mixin.ts
@@ -0,0 +1,104 @@
+import {ExportNs} from '../../../esl-utils/environment/export-ns';
+import {attr, prop, listen} from '../../../esl-utils/decorators';
+import {getTouchPoint, isMouseEvent, isTouchEvent} from '../../../esl-utils/dom';
+import {ESLMediaQuery} from '../../../esl-media-query/core';
+
+import {ESLCarouselPlugin} from '../esl-carousel.plugin';
+
+import type {Point} from '../../../esl-utils/dom/events';
+
+
+/**
+ * {@link ESLCarousel} Touch handler mixin
+ *
+ * Usage:
+ * ```
+ * <esl-carousel esl-carousel-touch="all"></esl-carousel>
+ *
+ * <esl-carousel esl-carousel-touch="@mobile"></esl-carousel>
+ * ```
+ */
+@ExportNs('Carousel.Touch')
+export class ESLCarouselTouchMixin extends ESLCarouselPlugin {
+  public static override is = 'esl-carousel-touch';
+
+  /** Min distance in pixels to activate drugging mode */
+  @prop(5) public tolerance: number;
+
+  /** {@link ESLMediaQuery} condition to have touch support active */
+  @attr({name: ESLCarouselTouchMixin.is}) public media: string;
+
+  /** Point to start from */
+  protected startPoint: Point = {x: 0, y: 0};
+  /** Marker whether touch event is started */
+  protected isTouchStarted = false;
+
+  /** @returns marker whether the event should be ignored. */
+  protected isIgnoredEvent(event: TouchEvent | PointerEvent | MouseEvent): boolean | undefined {
+    // No nav required
+    if (this.$host.size <= this.$host.config.count) return true;
+    // Check for media condition
+    if (!ESLMediaQuery.for(this.media).matches) return true;
+    // Multi-touch gesture
+    if (isTouchEvent(event) && event.touches.length !== 1) return true;
+    // Non-primary mouse button initiate drug event
+    if (isMouseEvent(event) && event.button !== 0) return true;
+    // Check for form target
+    return !!(event.target as HTMLElement).closest('input, textarea, [editable]');
+  }
+
+  /** Handles `mousedown` / `touchstart` event to manage thumb drag start and scroll clicks */
+  @listen('mousedown touchstart')
+  protected _onPointerDown(event: MouseEvent | TouchEvent): void {
+    if (this.isTouchStarted || !this.$host.renderer || this.$host.animating) return;
+
+    this.isTouchStarted = !this.isIgnoredEvent(event);
+    if (!this.isTouchStarted) return;
+
+    this.startPoint = getTouchPoint(event);
+
+    isMouseEvent(event) && this.$$on({event: 'mousemove', target: window}, this._onPointerMove);
+    isMouseEvent(event) && this.$$on({event: 'mouseup', target: window}, this._onPointerUp);
+    isTouchEvent(event) && this.$$on({event: 'touchmove', target: window}, this._onPointerMove);
+    isTouchEvent(event) && this.$$on({event: 'touchend', target: window}, this._onPointerUp);
+  }
+
+  /** Processes `mousemove` and `touchmove` events. */
+  protected _onPointerMove(event: TouchEvent | PointerEvent | MouseEvent): void {
+    if (!this.isTouchStarted) return;
+    const point = getTouchPoint(event);
+    const offset = point.x - this.startPoint.x;
+
+    if (!this.$host.hasAttribute('dragging')) {
+      if (Math.abs(offset) < this.tolerance) return;
+      this.$$attr('dragging', true);
+    }
+
+    event.preventDefault();
+    // ignore single click
+    offset !== 0 && this.$host.renderer.onMove(offset);
+  }
+
+  /** Processes `mouseup` and `touchend` events. */
+  protected _onPointerUp(event: TouchEvent | PointerEvent | MouseEvent): void {
+    // Unbinds drag listeners
+    this.$$off(this._onPointerMove);
+    this.$$off(this._onPointerUp);
+
+    this.isTouchStarted = false;
+
+    if (this.$$attr('dragging', false) !== null) {
+      event.preventDefault();
+      const point = getTouchPoint(event);
+      const offset = point.x - this.startPoint.x;
+      // ignore single click
+      offset !== 0 && this.$host.renderer.commit(offset);
+    }
+  }
+}
+
+declare global {
+  export interface ESLCarouselNS {
+    Touch: typeof ESLCarouselTouchMixin;
+  }
+}
diff --git a/src/modules/esl-carousel/renderers/multi/esl-multi-carousel.less b/src/modules/esl-carousel/renderers/multi/esl-multi-carousel.less
new file mode 100644
index 000000000..57eab52ca
--- /dev/null
+++ b/src/modules/esl-carousel/renderers/multi/esl-multi-carousel.less
@@ -0,0 +1,15 @@
+esl-carousel.multi-carousel {
+  @SLIDE_CAROUSEL_ANIMATION: .25s linear;
+  &[animating] {
+    [data-slides-area] {
+      // animation
+      transition: transform @SLIDE_CAROUSEL_ANIMATION;
+    }
+  }
+
+  esl-carousel-slide {
+    &:not([visible]):not([active]) {
+      visibility: hidden;
+    }
+  }
+}
diff --git a/src/modules/esl-carousel/renderers/multi/esl-multi-carousel.ts b/src/modules/esl-carousel/renderers/multi/esl-multi-carousel.ts
new file mode 100644
index 000000000..a5301e08e
--- /dev/null
+++ b/src/modules/esl-carousel/renderers/multi/esl-multi-carousel.ts
@@ -0,0 +1,211 @@
+import {promisifyEvent, promisifyTransition, resolvePromise} from '../../../esl-utils/async';
+
+import {boundIndex, calcDirection, normalizeIndex} from '../../core/nav/esl-carousel.nav.utils';
+import {ESLCarouselRenderer} from '../../core/esl-carousel.renderer';
+import {ESLCarouselSlideEvent} from '../../core/esl-carousel.events';
+
+import type {ESLCarouselDirection} from '../../core/nav/esl-carousel.nav.types';
+
+@ESLCarouselRenderer.register
+export class ESLMultiCarouselRenderer extends ESLCarouselRenderer {
+  public static override is = 'multi';
+
+  /** Slides gap width */
+  protected gapWidth: number = 0;
+  /** Slide width cached value */
+  protected slideWidth: number = 0;
+  /** First index of active slides. */
+  protected currentIndex: number = 0;
+
+  /**
+   * Processes binding of defined renderer to the carousel {@link ESLCarousel}.
+   * Prepare to renderer animation.
+   */
+  public override onBind(): void {
+    this.currentIndex = this.carousel.activeIndex;
+    this.redraw();
+  }
+
+  public override redraw(): void {
+    this.resize();
+    this.reindex();
+    this.setActive(this.currentIndex);
+  }
+
+  /**
+   * Processes unbinding of defined renderer from the carousel {@link ESLCarousel}.
+   * Clear animation.
+   */
+  public override onUnbind(): void {
+    this.carousel.$slides.forEach((el) => {
+      el.toggleAttribute('visible', false);
+      el.style.removeProperty('order');
+      el.style.removeProperty('width');
+    });
+    this.$area.style.removeProperty('transform');
+    this.$area.style.transform = 'translate3d(0px, 0px, 0px)';
+    this.carousel.toggleAttribute('animating', false);
+  }
+
+  /** Pre-processing animation action. */
+  public async onBeforeAnimate(): Promise<void> {
+    if (this.carousel.hasAttribute('animating')) return Promise.reject('Already animating');
+    this.carousel.$slides.forEach((el) => el.toggleAttribute('visible', true));
+    return Promise.resolve();
+  }
+
+  /** Processes animation. */
+  public async onAnimate(nextIndex: number, direction: ESLCarouselDirection): Promise<void> {
+    const {activeIndex, $slidesArea} =  this.carousel;
+    this.currentIndex = activeIndex;
+    if (!$slidesArea) return;
+    while (this.currentIndex !== nextIndex) {
+      await this.onBeforeStepAnimate(direction);
+      await this.onAfterStepAnimate(direction);
+    }
+  }
+
+  /** Post-processing animation action. */
+  public async onAfterAnimate(): Promise<void> {
+    this.$area.style.transform = 'translate3d(0px, 0px, 0px)';
+    this.carousel.$slides.forEach((el) => el.removeAttribute('visible'));
+    return Promise.resolve();
+  }
+
+  /** Pre-processing the transition animation of one slide. */
+  protected async onBeforeStepAnimate(direction: ESLCarouselDirection): Promise<void> {
+    const orderIndex = direction === 'next' ? this.currentIndex : normalizeIndex(this.currentIndex - 1, this.size);
+    this.reindex(orderIndex);
+
+    // TODO: reflow
+    const offsetIndex = direction === 'next' ? normalizeIndex(this.currentIndex + 1, this.size) : this.currentIndex;
+    const offset = this.carousel.$slides[offsetIndex].offsetLeft;
+    const shiftXBefore = direction === 'next' ? 0 : -offset;
+    this.$area.style.transform = `translate3d(${shiftXBefore}px, 0px, 0px)`;
+
+    +this.carousel.offsetLeft;
+    this.carousel.toggleAttribute('animating', true);
+
+    const shiftXAfter = direction === 'next' ? -offset : 0;
+    this.$area.style.transform = `translate3d(${shiftXAfter}px, 0px, 0px)`;
+
+    return promisifyTransition(this.$area, 'transform');
+  }
+
+  /** Post-processing the transition animation of one slide. */
+  protected async onAfterStepAnimate(direction: ESLCarouselDirection): Promise<void> {
+    // TODO: onAfterAnimate
+    this.$area.style.transform = 'translate3d(0px, 0px, 0px)';
+
+    this.currentIndex = direction === 'next' ? this.currentIndex + 1 : this.currentIndex - 1;
+    this.currentIndex = normalizeIndex(this.currentIndex, this.size);
+
+    this.reindex(this.currentIndex);
+
+    this.carousel.toggleAttribute('animating', false);
+    +this.carousel.offsetLeft;
+    return Promise.resolve();
+  }
+
+  /** Handles the slides transition. */
+  public onMove(offset: number): void {
+    this.carousel.$slides.forEach((el) => el.toggleAttribute('visible', true));
+
+    const sign = offset < 0 ? 1 : -1;
+    const slideWidth = this.slideWidth + this.gapWidth;
+    const count = Math.floor(Math.abs(offset) / slideWidth);
+    const currentIndex = normalizeIndex(this.carousel.activeIndex + count * sign, this.size);
+
+    if (!this._checkNonLoop(offset)) return;
+
+    const orderIndex = offset < 0 ? currentIndex : normalizeIndex(currentIndex - 1, this.size);
+    this.reindex(orderIndex);
+    this.currentIndex = currentIndex;
+
+    const stageOffset = offset < 0 ? offset + count * slideWidth : offset - (count + 1) * slideWidth;
+    this.$area.style.transform = `translateX(${stageOffset}px)`;
+  }
+
+  protected _checkNonLoop(offset: number): boolean {
+    const sign = offset < 0 ? 1 : -1;
+    const count = Math.floor(Math.abs(offset) / (this.slideWidth + this.gapWidth));
+    const nextIndex = this.carousel.activeIndex + count * sign;
+    const currentIndex = normalizeIndex(this.carousel.activeIndex + count * sign, this.size);
+
+    if (this.loop) return true;
+    // check non-loop state
+    if (nextIndex >= this.carousel.size || nextIndex < 0) return false;
+    // check left border of non-loop state
+    if (offset > 0 && currentIndex - 1 < 0) return false;
+    // check right border of non-loop state
+    return !(offset < 0 && currentIndex + 1 + this.count > this.carousel.size);
+  }
+
+  /** Ends current transition and make permanent all changes performed in the transition. */
+  // eslint-disable-next-line sonarjs/cognitive-complexity
+  public async commit(offset: number): Promise<void> {
+    const activeIndex = this.carousel.activeIndex;
+    const achieveBorders = this._checkNonLoop(offset);
+    if (achieveBorders) {
+      const slideWidth = this.slideWidth + this.gapWidth;
+      // calculate offset to move to
+      const shiftCount = Math.abs(offset) % slideWidth >= slideWidth / 4 ? 1 : 0;
+      const stageOffset = offset < 0 ? -shiftCount * slideWidth : (shiftCount - 1) * slideWidth;
+
+      this.carousel.toggleAttribute('animating', true);
+      this.$area.style.transform = `translateX(${stageOffset}px)`;
+      await promisifyEvent(this.$area, 'transitionend').catch(resolvePromise);
+    }
+
+    // clear animation
+    this.carousel.toggleAttribute('animating', false);
+    this.$area.style.transform = 'translateX(0)';
+    this.carousel.$slides.forEach((el) => el.toggleAttribute('visible', false));
+
+    const sign = offset < 0 ? 1 : -1;
+    const count = Math.abs(offset) % this.slideWidth >= this.slideWidth / 4 ?
+      Math.ceil(Math.abs(offset) / this.slideWidth) : Math.floor(Math.abs(offset) / this.slideWidth);
+    const nextIndex = this.carousel.activeIndex + count * sign;
+
+    if (this.loop) {
+      this.currentIndex = normalizeIndex(nextIndex, this.size);
+    } else {
+      this.currentIndex = boundIndex(nextIndex, this.size - this.count);
+    }
+
+    let direction: ESLCarouselDirection = offset > 0 ? 'prev' : 'next';
+    direction = direction || calcDirection(this.carousel.activeIndex, this.currentIndex, this.size);
+    this.reindex(this.currentIndex);
+
+    this.setActive(this.currentIndex);
+
+    if (activeIndex !== this.currentIndex) {
+      this.carousel.dispatchEvent(ESLCarouselSlideEvent.create('AFTER', {
+        direction,
+        current: this.currentIndex,
+        related: activeIndex
+      }));
+    }
+  }
+
+  /** Sets order style property for slides starting at index */
+  protected resize(): void {
+    const {$slides, $slidesArea} = this.carousel;
+    if (!$slidesArea || !$slides.length) return;
+    const slidesAreaStyles = getComputedStyle($slidesArea);
+    const width = parseFloat(slidesAreaStyles.width);
+    this.gapWidth = parseFloat(slidesAreaStyles.columnGap);
+    this.slideWidth = (width - this.gapWidth * (this.count - 1)) / this.count;
+    $slides.forEach((slide) => slide.style.minWidth = this.slideWidth + 'px');
+  }
+
+  /** Sets order style property for slides starting at index */
+  protected reindex(index: number = this.currentIndex): void {
+    if (index < 0 || index > this.carousel.size) return;
+    const {size, $slides} = this.carousel;
+    if (!$slides.length) return;
+    for (let i = 0; i < size; ++i) {
+      $slides[i].style.order = String((size + i - index) % size);
+    }
+  }
+}


Test patch:
diff --git a/pages/src/esl-carousel-demo/test-carousel.less b/pages/src/esl-carousel-demo/test-carousel.less
new file mode 100644
index 000000000..0ab63e532
--- /dev/null
+++ b/pages/src/esl-carousel-demo/test-carousel.less
@@ -0,0 +1,74 @@
+@import (reference) "../common/variables.less";
+
+.marquee-carousel [esl-carousel-container] {
+  esl-carousel {
+    margin: 0;
+    padding: 0;
+  }
+  .img-container {
+    width: 100%;
+    height: 100%;
+  }
+
+  button.arrow-prev {
+    left: 0;
+    &::before {
+      background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='50' height='50' fill='%23fff'%3E%3Cpath d='M31 40 16 25l15-15 2 2-13 13 13 13z'/%3E%3C/svg%3E");
+    }
+  }
+  button.arrow-next {
+    right: 0;
+    &::before {
+      background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='50' height='50' fill='%23fff'%3E%3Cpath d='M31 40 16 25l15-15 2 2-13 13 13 13z'/%3E%3C/svg%3E");
+    }
+  }
+  button.arrow-prev::before, button.arrow-next::before {
+    width: 60px;
+    height: 60px;
+  }
+  .carousel-dots-wrapper {
+    position: absolute;
+    bottom: 50px;
+    left: 50%;
+    margin-right: -50%;
+    transform: translate(-50%, 0%);
+    @media @lg-xl {
+      display: none;
+    }
+  }
+
+  &.main-carousel {
+   div[data-slides-area] {
+     min-height: 500px;
+     gap: 0;
+   }
+   esl-carousel-slide {
+     padding: 0;
+   }
+    button.arrow-prev, button.arrow-next {
+      @media @lg-xl {
+        display: none;
+      }
+    }
+  }
+
+  &.marquee-nav-carousel {
+    padding: 0 60px;
+    esl-carousel-slide {
+      margin: 10px 0;
+    }
+    &::before {
+      position: absolute;
+      content: '';
+      background: black;
+      height: 100%;
+      width: 100%;
+      top: 0;
+      left: 0;
+      overflow: hidden;
+    }
+    @media @xs-md {
+      display: none;
+    }
+  }
+}
diff --git a/src/modules/esl-carousel/test/nav/esl-carousel.nav-utils.test.ts b/src/modules/esl-carousel/test/nav/esl-carousel.nav-utils.test.ts
new file mode 100644
index 000000000..11ce29487
--- /dev/null
+++ b/src/modules/esl-carousel/test/nav/esl-carousel.nav-utils.test.ts
@@ -0,0 +1,282 @@
+import {
+  normalizeIndex,
+  calcDirection,
+  groupToIndex,
+  indexToGroup,
+  toIndex
+} from '../../core/nav/esl-carousel.nav.utils';
+
+import type {
+  ESLCarouselDirection,
+  ESLCarouselSlideTarget,
+  ESLCarouselState
+} from '../../core/nav/esl-carousel.nav.types';
+
+describe('ESLCarousel: Nav Utils', () => {
+  describe('normalizeIndex', () => {
+    test.each([
+      // [index, size, result]
+      [0, 5, 0],
+      [4, 5, 4],
+      [10, 10, 0],
+      [11, 10, 1],
+      [-1, 10, 9],
+      [21, 10, 1],
+      [-11, 10, 9],
+      [-21, 10, 9]
+    ])(
+      '(index = %d, size = %d) => %d',
+      (i: number, size: number, result: number) => expect(normalizeIndex(i, size)).toBe(result)
+    );
+  });
+
+  describe('calcDirection', () => {
+    test.each([
+      // [from index, to index, size, result]
+      [0, 1, 5, 'next'],
+      [1, 3, 5, 'next'],
+      [3, 2, 5, 'prev'],
+      [1, 4, 5, 'prev'],
+      [1, 7, 5, 'next'],
+      [7, 1, 5, 'prev']
+    ])(
+      '(from = %d, to = %d, size = %d) => %s',
+      (from: number, to: number, count: number, result: ESLCarouselDirection) => expect(calcDirection(from, to, count)).toBe(result)
+    );
+  });
+
+  describe('groupToIndex', () => {
+    test.each([
+      // [group index, active count, size, result]
+      [0, {count: 4, size: 5}, 0],
+      [1, {count: 4, size: 5}, 1],
+      [0, {count: 3, size: 8}, 0],
+      [1, {count: 3, size: 8}, 3],
+      [2, {count: 3, size: 8}, 5],
+      [2, {count: 3, size: 9}, 6],
+      [5, {count: 1, size: 5}, 0]
+    ])(
+      '(group = %d, %o) => %d',
+      (group: number, {count, size}: {count: number, size: number}, result: number) => expect(groupToIndex(group, count, size)).toBe(result)
+    );
+  });
+
+  describe('indexToGroup', () => {
+    test.each([
+      // [index, active count, size, result]
+      [0, {count: 6, size: 15}, 0],
+      [1, {count: 6, size: 15}, 0],
+      [2, {count: 6, size: 15}, 0],
+      [3, {count: 6, size: 15}, 0],
+      [4, {count: 6, size: 15}, 1],
+      [5, {count: 6, size: 15}, 1],
+      [6, {count: 6, size: 15}, 1],
+      [7, {count: 6, size: 15}, 1],
+      [8, {count: 6, size: 15}, 2],
+      [9, {count: 6, size: 15}, 2],
+      [10, {count: 6, size: 15}, 2],
+      [11, {count: 6, size: 15}, 2],
+      [12, {count: 6, size: 15}, 2],
+      [13, {count: 6, size: 15}, 0],
+      [14, {count: 6, size: 15}, 0],
+
+      [0, {count: 3, size: 5}, 0],
+      [1, {count: 3, size: 5}, 0],
+      [2, {count: 3, size: 5}, 1],
+      [3, {count: 3, size: 5}, 1],
+      [4, {count: 3, size: 5}, 0],
+
+      [1, {count: 2, size: 4}, 0],
+      [3, {count: 2, size: 4}, 1],
+
+      [0, {count: 3, size: 4}, 0],
+      [1, {count: 3, size: 4}, 1],
+      [2, {count: 3, size: 4}, 1],
+      [3, {count: 3, size: 4}, 0],
+
+      [2, {count: 3, size: 8}, 1],
+      [4, {count: 3, size: 8}, 1],
+      [6, {count: 3, size: 8}, 2],
+      [7, {count: 3, size: 8}, 0],
+
+      [1, {count: 2, size: 3}, 1],
+      [2, {count: 2, size: 3}, 1],
+
+      [0, {count: 2, size: 9}, 0],
+      [1, {count: 2, size: 9}, 0],
+      [2, {count: 2, size: 9}, 1],
+      [3, {count: 2, size: 9}, 1],
+      [4, {count: 2, size: 9}, 2],
+      [5, {count: 2, size: 9}, 2],
+      [6, {count: 2, size: 9}, 3],
+      [7, {count: 2, size: 9}, 4],
+      [8, {count: 2, size: 9}, 4],
+
+      [0, {count: 4, size: 9}, 0],
+      [1, {count: 4, size: 9}, 0],
+      [2, {count: 4, size: 9}, 0],
+      [3, {count: 4, size: 9}, 1],
+      [4, {count: 4, size: 9}, 1],
+      [5, {count: 4, size: 9}, 2],
+      [6, {count: 4, size: 9}, 2],
+      [7, {count: 4, size: 9}, 2],
+      [8, {count: 4, size: 9}, 0],
+
+      [0, {count: 5, size: 9}, 0],
+      [1, {count: 5, size: 9}, 0],
+      [2, {count: 5, size: 9}, 0],
+      [3, {count: 5, size: 9}, 1],
+      [4, {count: 5, size: 9}, 1],
+      [5, {count: 5, size: 9}, 1],
+      [6, {count: 5, size: 9}, 1],
+      [7, {count: 5, size: 9}, 0],
+      [8, {count: 5, size: 9}, 0],
+
+      [0, {count: 6, size: 9}, 0],
+      [1, {count: 6, size: 9}, 0],
+      [2, {count: 6, size: 9}, 1],
+      [3, {count: 6, size: 9}, 1],
+      [4, {count: 6, size: 9}, 1],
+      [5, {count: 6, size: 9}, 1],
+      [6, {count: 6, size: 9}, 1],
+      [7, {count: 6, size: 9}, 0],
+      [8, {count: 6, size: 9}, 0],
+
+      [0, {count: 1, size: 9}, 0],
+      [1, {count: 1, size: 9}, 1],
+      [2, {count: 1, size: 9}, 2],
+      [3, {count: 1, size: 9}, 3],
+      [4, {count: 1, size: 9}, 4],
+      [5, {count: 1, size: 9}, 5],
+      [6, {count: 1, size: 9}, 6],
+      [7, {count: 1, size: 9}, 7],
+      [8, {count: 1, size: 9}, 8],
+
+    ])(
+      '(index = %d, %o) => %d',
+      (index: number, {count, size}: {count: number, size: number}, result: number) => expect(indexToGroup(index, count, size)).toBe(result)
+    );
+  });
+
+  describe('toIndex', () => {
+    describe('numeric input', () => {
+      test.each([
+        [0, {size: 5, count: 1, activeIndex: 2, loop: false}, 0],
+        [1, {size: 5, count: 2, activeIndex: 2, loop: false}, 1],
+        [5, {size: 5, count: 1, activeIndex: 0, loop: false}, 4],
+        [6, {size: 5, count: 3, activeIndex: 1, loop: false}, 2],
+        [5, {size: 5, count: 1, activeIndex: 0, loop: true}, 0],
+        [6, {size: 5, count: 3, activeIndex: 1, loop: true}, 1]
+      ])(
+        '(target = %s, cfg = %p) = %d',
+        (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result)
+      );
+    });
+
+    describe('numeric string input', () => {
+      test.each([
+        [' 0', {size: 5, count: 1, activeIndex: 2, loop: false}, 0],
+        [' 1 ', {size: 5, count: 2, activeIndex: 2, loop: false}, 1],
+        ['5', {size: 5, count: 1, activeIndex: 0, loop: false}, 4],
+        [' 6 ', {size: 5, count: 3, activeIndex: 1, loop: false}, 2],
+        [' 0', {size: 5, count: 1, activeIndex: 2, loop: true}, 0],
+        [' 1 ', {size: 5, count: 2, activeIndex: 2, loop: true}, 1],
+        ['5', {size: 5, count: 1, activeIndex: 0, loop: true}, 0],
+        [' 6 ', {size: 5, count: 3, activeIndex: 1, loop: true}, 1]
+      ])(
+        '(target = %s, cfg = %p) = %d',
+        (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result)
+      );
+    });
+
+    describe('relative slide short target', () => {
+      test.each([
+        ['prev', {size: 5, count: 1, activeIndex: 2}, 1],
+        ['next', {size: 5, count: 2, activeIndex: 2}, 3]
+      ])(
+        '(target = %s, cfg = %p) = %d',
+        (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result)
+      );
+    });
+
+    describe('relative slide target', () => {
+      test.each([
+        ['-1', {size: 5, count: 1, activeIndex: 2, loop: false}, 1],
+        ['+1', {size: 5, count: 2, activeIndex: 2, loop: false}, 3],
+        ['-3', {size: 5, count: 1, activeIndex: 2, loop: false}, 0],
+        ['+3', {size: 5, count: 3, activeIndex: 2, loop: false}, 2],
+        ['-3', {size: 5, count: 1, activeIndex: 2, loop: true}, 4],
+        ['+3', {size: 5, count: 3, activeIndex: 2, loop: true}, 0]
+      ])(
+        '(target = %s, cfg = %p) = %d',
+        (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result)
+      );
+    });
+
+    describe('slide target absolute full', () => {
+      test.each([
+        ['slide:0', {size: 5, count: 1, activeIndex: 2}, 0],
+        ['slide:1', {size: 5, count: 2, activeIndex: 2}, 1]
+      ])(
+        '(target = %s, cfg = %p) = %d',
+        (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result)
+      );
+    });
+
+    describe('slide target relative full', () => {
+      test.each([
+        ['slide:-1', {size: 5, count: 1, activeIndex: 2, loop: false}, 1],
+        ['slide:+1', {size: 5, count: 2, activeIndex: 2, loop: false}, 3],
+        ['slide:-3', {size: 5, count: 1, activeIndex: 2, loop: true}, 4],
+        ['slide:+3', {size: 5, count: 3, activeIndex: 2, loop: true}, 0],
+        ['slide:-3', {size: 5, count: 1, activeIndex: 2, loop: false}, 0],
+        ['slide:+3', {size: 5, count: 3, activeIndex: 2, loop: false}, 2]
+      ])(
+        '(target = %s, cfg = %p) = %d',
+        (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result)
+      );
+    });
+
+    describe('group direct target', () => {
+      test.each([
+        ['group: 0', {size: 8, count: 3, activeIndex: 0}, 0],
+        ['group: 1', {size: 8, count: 3, activeIndex: 1}, 3],
+        ['group: 2', {size: 8, count: 3, activeIndex: 2}, 5]
+      ])(
+        '(target = %s, cfg = %p) = %d',
+        (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result)
+      );
+    });
+
+    describe('group relative target', () => {
+      test.each([
+        ['group: +1', {size: 5, count: 1, activeIndex: 2}, 3],
+        ['group: +1', {size: 5, count: 2, activeIndex: 2}, 3],
+        ['group: -1', {size: 5, count: 1, activeIndex: 2}, 1],
+        ['group: -1', {size: 5, count: 2, activeIndex: 2}, 0]
+      ])(
+        '(target = %s, cfg = %p) = %d',
+        (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result)
+      );
+    });
+
+    describe('group relative short target', () => {
+      test.each([
+        ['group: next', {size: 5, count: 1, activeIndex: 2}, 3],
+        ['group: next', {size: 5, count: 2, activeIndex: 2}, 3],
+        ['group: next', {size: 5, count: 1, activeIndex: 4}, 0],
+        ['group: next', {size: 5, count: 2, activeIndex: 4}, 1],
+        ['group: next', {size: 5, count: 3, activeIndex: 0}, 2],
+
+        ['group: prev', {size: 5, count: 1, activeIndex: 2}, 1],
+        ['group: prev', {size: 5, count: 2, activeIndex: 2}, 0],
+        ['group: prev', {size: 5, count: 1, activeIndex: 0}, 4],
+        ['group: prev', {size: 5, count: 3, activeIndex: 1}, 0],
+        ['group: prev', {size: 5, count: 3, activeIndex: 4}, 1]
+      ])(
+        '(target = %s, cfg = %p) = %d',
+        (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result)
+      );
+    });
+  });
+});