
Продолжая тему из моей предыдущей статьи о веб-компонентах, я хочу подробнее рассмотреть их применение для решения реальных задач. Сегодня мы напишем простую, но полнофункциональную реализацию Слайдера, в процессе познакомившись с такими ключевыми концепциями, как Shadow DOM и Declarative Shadow DOM.
Что нам даёт использование Shadow DOM:
Возможность работать со слотами (
<slot>) для композиции контентаПолная изоляция стилей компонента от глобальных таблиц CSS
Инкапсуляция DOM-дерева компонента
Итак, существует два основных способа создания веб-компонента с Shadow DOM:
Императивный подход (в JavaScript-коде): использование метода
this.attachShadow({ mode: "open" })внутри класса компонента.Декларативный подход (в 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. Мы разберём ключевые аспекты взаимодействия, потенциальные проблемы и лучшие практики совместного использования этих технологий.
zababurin
А уничтожение где стоит ? по идее в disconectedCallback должно быть
Dima-Andreev Автор
Хорошее замечание! В данном примере отписку (removeeventlistener) делать не нужно тк Garbage Collection сделает свое дело. При удалении элемента из DOM дерева все слушатели автоматически удаляются. Но! есть нюанс: если бы я делал подписку скажем на document или любой внешний элемент. То да отписка в данном случае была бы обязательна.
Еще ксати заметил данный компонент который создан с использрованием declarative shadow dom нет возможности создать динамически или склонировать тк shadow dom в данном случае привязывается при загрузке страницы. И тут возможно стоит рассмотреть вариант с attachShadow в дополнении к declarative (на случай клонирования или если браузер не поддерживает)
zababurin
Я так обычно последнее время делаю. вроде работает