Продолжая тему из моей предыдущей статьи о веб-компонентах, я хочу подробнее рассмотреть их применение для решения реальных задач. Сегодня мы напишем простую, но полнофункциональную реализацию Слайдера, в процессе познакомившись с такими ключевыми концепциями, как Shadow DOM и Declarative Shadow DOM.

Что нам даёт использование Shadow DOM:

  1. Возможность работать со слотами (<slot>) для композиции контента

  2. Полная изоляция стилей компонента от глобальных таблиц CSS

  3. Инкапсуляция DOM-дерева компонента

Итак, существует два основных способа создания веб-компонента с Shadow DOM:

  1. Императивный подход (в JavaScript-коде): использование метода this.attachShadow({ mode: "open" }) внутри класса компонента.

  2. Декларативный подход (в HTML-разметке): с помощью атрибута shadowrootmode="open", который добавляется непосредственно к элементу <template>.

Рассмотрим каждый подробнее

Императивный подход

Рассмотрим базовую реализацию. Вот код, который создаёт основу для нашего компонента:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Components - Slider</title>
    <link rel="stylesheet" href="/app.css" />
</head>

<body>
    <slider-component></slider-component>
</body>

<script>
    class SliderComponent extends HTMLElement {
        constructor() {
            super()
            this._shadowRoot = this.attachShadow({ mode: "open" })
        }
    }

    customElements.define('slider-component', SliderComponent)
</script>
</html

Как видите, в конструкторе класса мы вызываем метод attachShadow с параметром mode: "open". Это создаёт открытое (open) Shadow DOM-дерево, прикреплённое к нашему элементу.

В инструментах разработки Chrome (DevTools) теперь можно увидеть, что элемент <slider-component> содержит #shadow-root (open), который визуально подсвечивается, показывая границы изолированного DOM-поддерева.

Декларативный подход

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Components - Slider</title>
    <link rel="stylesheet" href="/app.css" />
</head>

<body>
    <slider-component>
        <template shadowrootmode="open"></template>
    </slider-component>
</body>

<script>
    class SliderComponent extends HTMLElement {
        constructor() {
            super()
        }
    }

    customElements.define('slider-component', SliderComponent)
</script>
</html>

Мы добились аналогичного результата — компонент теперь использует Shadow DOM. Однако ключевое отличие в том, что при декларативном подходе Shadow DOM формируется немедленно, в момент загрузки HTML, ещё до выполнения какого-либо JavaScript-кода. Это открывает возможности для SSR (Server-Side Rendering) и обеспечивает более предсказуемое отображение контента.

Теперь продолжим создание слайдера. Для этого наполним наш компонент и приложение стилями. Создадим отдельный файл CSS для приложения а также один для изолированных стилей.

app.css

body {
    display: flex;
    padding: 1em;

    --bg: white;
    --shadow: 1px solid #eee;
    --slider--gap: 1em;
}

.slider {
    width: 305px;
}

.slider__item {
    background-color: #eee;
    width: 80px;
    height: 80px;
    border-radius: .8em;
}

slider.css:

:host {
  position: relative;
  display: flex;
  align-items: center;
  width: 100%;
  padding: 0 1em;
  box-sizing: border-box;
}

.slides {
  display: flex;
  gap: var(--slider--gap, 1em);
  overflow-x: scroll;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -ms-overflow-style: none;
}

::slotted(.slider__item) {
  flex-shrink: 0;
  scroll-snap-align: center;
}

:host::-webkit-scrollbar {
  display: none;
}

.navigation-control {
  position: absolute;
  width: 2em;
  height: 2em;
  background-color: var(--bg);
  border-radius: 50%;
  box-shadow: var(--shadow);
  display: flex;
  align-items: center;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: .5;
  outline: none;
  transition: .3s;

  &:hover,
  &:focus {
    opacity: 1;
    transform: scale(1.1);
  }
}

.navigation-control__left {
  left: .3em;
}

.navigation-control__right {
  right: .3em;
}

Как уже упоминалось ранее, ключевое преимущество Shadow DOM — это полная изоляция стилей. Классы, объявленные внутри CSS-файла, подключённого к компоненту, не будут конфликтовать с глобальными стилями и не попадут под влияние внешних CSS-правил.

Кроме того, появляется доступ к специальным CSS-селекторам, таким как:

  • :host — для стилизации самого элемента-хозяина;

  • ::slotted() — для стилизации контента, проецируемого в слоты.

Подробную информацию обо всех доступных селекторах и областях их применения можно найти в документации на MDN.

И ещё один важный нюанс: несмотря на изоляцию, CSS-переменные (Custom Properties) наследуются через границы Shadow DOM. Это позволяет гибко настраивать тему компонента извне. Отличной практикой является указание значения по умолчанию на случай, если переменная не задана: var(--slider--gap, 1em)

Теперь разберём структуру HTML для нашего slider-component. Всё содержимое, расположенное внутри элемента <template>, будет помещено в Shadow DOM компонента. Контент, размещённый непосредственно между тегами <slider-component></slider-component> в основном документе, будет проецироваться внутрь элемента <slot>, определённого в шаблоне.

index.html:

<slider-component>
    <template shadowrootmode="open">
        <link rel="stylesheet" href="/slider.css">

        <div class="navigation-control navigation-control__left" tabindex="0" data-control="prev">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" height="1em" width="1em">
                <path d="M642-48 209-480l433-432 103 103-329 329 329 329L642-48Z" />
            </svg>
        </div>
        <div class="navigation-control navigation-control__right" tabindex="0" data-control="next">
            <svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 -960 960 960" width="1em">
                <path d="M321-48 218-151l329-329-329-329 103-103 432 432L321-48Z" />
            </svg>
        </div>
        <slot class="slides" data-id="slides"></slot>
    </template>
    <h1>Hello World!</h1>
</slider-component>

В инструментах разработки Chrome (DevTools):

Слоты можно именовать. Для этого используется атрибут name: <slot name="slot-1"></slot>. Чтобы направить контент именно в этот слот, элементу в основном документе нужно указать соответствующий атрибут slot: <h1 slot="slot-1">Hello World!</h1>:

<slider-component>
    <template shadowrootmode="open">
        <link rel="stylesheet" href="/slider.css">

        <div class="navigation-control navigation-control__left" tabindex="0" data-control="prev">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" height="1em" width="1em">
                <path d="M642-48 209-480l433-432 103 103-329 329 329 329L642-48Z" />
            </svg>
        </div>
        <div class="navigation-control navigation-control__right" tabindex="0" data-control="next">
            <svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 -960 960 960" width="1em">
                <path d="M321-48 218-151l329-329-329-329 103-103 432 432L321-48Z" />
            </svg>
        </div>
        <slot class="slides" data-id="slides"></slot>
    </template>

    <div class="slider__item" data-id="slider-item"></div>
    <div class="slider__item" data-id="slider-item"></div>
    <div class="slider__item" data-id="slider-item"></div>
    <div class="slider__item" data-id="slider-item"></div>
    <div class="slider__item" data-id="slider-item"></div>
    <div class="slider__item" data-id="slider-item"></div>
    <div class="slider__item" data-id="slider-item"></div>
    <div class="slider__item" data-id="slider-item"></div>
    <div class="slider__item" data-id="slider-item"></div>
    <div class="slider__item" data-id="slider-item"></div>
</slider-component>

Добавим корневому элементу slider-component CSS-класс slider:

<slider-component class="slider"></slider-component>

Поскольку этот класс применяется к самому пользовательскому элементу (к «хосту»), а не к содержимому внутри Shadow DOM, он будет доступен в глобальной области видимости. Соответственно, стили для этого класса будут применяться из основной таблицы стилей app.css.

Осталось добавить логику обработки клика:

class SliderComponent extends HTMLElement {
        constructor() {
            super()
            this.slidesEl = this.shadowRoot.querySelector('[data-id=slides]')
        }

        get deltaX() {
            return this.hasAttribute('delta-x') ? Number(this.getAttribute('delta-x')) : 100
        }

        connectedCallback() {
            this.shadowRoot.addEventListener('click', e => {
                const { target } = e
                const controlEl = target.closest('[data-control]')
                if (!controlEl) {
                    return
                }
                const { control } = controlEl.dataset
                if (control === 'next') {
                    this.#handleNext()
                } else if (control === 'prev') {
                    this.#handlePrev()
                } else {
                    console.error('Invalid control value')
                }
            })
        }

        #handleNext() {
            this.slidesEl.scrollTo({ behavior: "smooth", left: this.slidesEl.scrollLeft + this.deltaX })
        }

        #handlePrev() {
            this.slidesEl.scrollTo({ behavior: "smooth", left: this.slidesEl.scrollLeft - this.deltaX })
        }
    }

    customElements.define('slider-component', SliderComponent)

Итак, в конструкторе я добавил ссылку на элемент слайдера this.slidesEl, чтобы избежать многократного поиска в DOM при каждом клике.

Также было добавлено вычисляемое свойство deltaX, которое определяет величину сдвига слайдов при клике на кнопки навигации. Его значение берётся из атрибута компонента delta-x или, если атрибут не задан, используется значение по умолчанию — 100.

В методе connectedCallback (который вызывается когда компонент добавляется в DOM) регистрируется обработчик кликов. Он анализирует атрибут data-control нажатой кнопки и определяет направление прокрутки — влево или вправо, после чего выполняет соответствующий сдвиг на вычисленное значение deltaX.

Таким образом, мы получаем готовый к использованию слайдер. Для управления его поведением можно:

  • Задать атрибут delta-x= для контроля величины сдвига

  • Настраивать внешний вид через CSS-переменные, например --bg --slider--gap

В этой статье мы создали полнофункциональный слайдер с использованием Web Components. Получился универсальный компонент, который можно легко встроить в любой проект и гибко настроить через CSS-переменные и HTML-атрибуты.

В рамках следующей статьи я планирую рассмотреть интеграцию Веб-компонентов с популярными JavaScript-фреймворками и библиотеками, такими как React, Vue и Angular. Мы разберём ключевые аспекты взаимодействия, потенциальные проблемы и лучшие практики совместного использования этих технологий.

Комментарии (3)


  1. zababurin
    10.11.2025 09:28

    connectedCallback() { this.shadowRoot.addEventListener('click', e => { const { target } = e const controlEl = target.closest('[data-control]') if (!controlEl) { return } const { control } = controlEl.dataset if (control === 'next') { this.#handleNext() } else if (control === 'prev') { this.#handlePrev() } else { console.error('Invalid control value') } }) }

    А уничтожение где стоит ? по идее в disconectedCallback должно быть


    1. Dima-Andreev Автор
      10.11.2025 09:28

      Хорошее замечание! В данном примере отписку (removeeventlistener) делать не нужно тк Garbage Collection сделает свое дело. При удалении элемента из DOM дерева все слушатели автоматически удаляются. Но! есть нюанс: если бы я делал подписку скажем на document или любой внешний элемент. То да отписка в данном случае была бы обязательна.

      Еще ксати заметил данный компонент который создан с использрованием declarative shadow dom нет возможности создать динамически или склонировать тк shadow dom в данном случае привязывается при загрузке страницы. И тут возможно стоит рассмотреть вариант с attachShadow в дополнении к declarative (на случай клонирования или если браузер не поддерживает)


      1. zababurin
        10.11.2025 09:28

            let eventListeners = [];
        
            const addEventListener = (element, event, handler) => {
                element.addEventListener(event, handler);
                eventListeners.push({ element, event, handler });
            };
        
            const removeAllEventListeners = () => {
                eventListeners.forEach(({ element, event, handler }) => {
                    element.removeEventListener(event, handler);
                });
                eventListeners = [];
            };
        
        
           const compareBtn = shadow.querySelector('#compare-now');
                if (compareBtn) {
                       addEventListener(compareBtn, 'click', async () => {
                       console.log('Compare Now clicked');
                       await context.compareNow();
                       exportBtn.disabled = false
                   });
                }
        
        
           removeAllEventListeners();



        Я так обычно последнее время делаю. вроде работает