Стилизация Shadow DOM
Стилизация веб-компонентов через Shadow DOM: :host, :host(), :host-context(), ::slotted(), CSS-переменные и adoptedStyleSheets.
Shadow DOM даёт компоненту собственное приватное DOM-дерево и собственные приватные стили. Эта страница посвящена стилизации: как CSS внутри shadow root инкапсулируется, какие специальные селекторы используются для обращения к хост-элементу и к слотированному содержимому (:host, :host(), :host-context(), ::slotted()), как разрешить внешнему CSS настраивать компонент намеренно (пользовательские свойства) и двумя способами подключить таблицу стилей (<style> и adoptedStyleSheets).
Если Shadow DOM вам незнаком, сначала прочитайте JavaScript Shadow DOM, а раздел Web Components поможет понять более широкую картину того, где используются shadow roots.
Почему стили инкапсулируются
Главное обещание Shadow DOM — двусторонняя граница стилей:
- Внешний CSS не проникает внутрь. Правило
p { color: red }, заданное для всей страницы, не затронет<p>внутри shadow root. Именно это делает компоненты безопасными для использования на любой странице. - Внутренний CSS не вытекает наружу. Стили внутри shadow root применяются только в пределах этого root, поэтому можно использовать короткие, обобщённые селекторы (
button,p,.title), не беспокоясь о конфликтах с хост-страницей.
Это отличается от обычной модели стилей и классов, где каждый селектор соперничает в одной глобальной области видимости. Внутри shadow root область видимости является областью по умолчанию.
Создание shadow root
Для начала прикрепите shadow root к хост-элементу. Всё, что вы поместите внутрь — разметка и CSS — будет инкапсулировано.
<body>
<div id="my-element"></div>
<script>
// Creating Shadow DOM
const shadowRoot = document.getElementById('my-element').attachShadow({ mode: 'open' });
// Styling Shadow DOM
shadowRoot.innerHTML = `
<p>A simple shadow root content.</p>
`;
</script>
</body>Здесь мы прикрепляем shadow root методом attachShadow() и устанавливаем mode в 'open', что позволяет впоследствии получить root через element.shadowRoot. ('closed' скрывает его от внешних скриптов, но не добавляет реальной безопасности.)
Добавление ограниченных стилей с помощью <style>
Самый простой способ стилизовать shadow root — поместить внутрь элемент <style>. Такие правила применяются только в пределах root, а правила страницы остаются снаружи.
<div id="my-element">
<!-- Shadow DOM content -->
</div>
<script>
const shadowRoot = document.getElementById('my-element').attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
/* Scoped styles */
:host {
display: block;
border: 2px solid #333;
padding: 10px;
}
p {
color: blue;
}
</style>
<p>This paragraph is styled within the Shadow DOM.</p>
`;
</script>Правило p { color: blue } окрашивает только абзац внутри этого root — <p> в другом месте страницы не затрагивается. Правило :host (ниже) стилизует сам хост-элемент.
Обращение к хосту: :host, :host(), :host-context()
Shadow root не может обращаться к своему хост-элементу с помощью обычного селектора, поскольку хост находится за пределами root. Три псевдокласса перекрывают этот пробел:
| Селектор | Совпадает | Для чего использовать |
|---|---|---|
:host | Хост-элемент, всегда | Базовые стили компонента (display, padding, блочная модель). |
:host(<selector>) | Хост только когда совпадает с <selector> | Варианты и состояния, управляемые атрибутами/классами/псевдоклассами, например :host([disabled]), :host(:hover). |
:host-context(<selector>) | Хост, когда предок совпадает с <selector> | Адаптация к контексту, например :host-context(.dark-theme). |
<div class="dark-theme">
<fancy-box disabled>Boxed content</fancy-box>
</div>
<script>
class FancyBox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host {
display: block;
padding: 12px;
border: 2px solid #007bff;
}
/* Variant: applies only when the host has [disabled] */
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
/* Context: applies when any ancestor has .dark-theme */
:host-context(.dark-theme) {
background: #1e1e1e;
color: #fff;
}
</style>
<slot></slot>
`;
}
}
customElements.define('fancy-box', FancyBox);
</script>Поскольку хост-элемент изначально имеет [disabled] и находится внутри .dark-theme, применяются все три правила: он отображается тёмным, приглушённым и неинтерактивным.
:host-context() имеет ограниченную поддержку в браузерах (на момент написания Firefox не поддерживает). Для широкой совместимости лучше использовать CSS-переменную или явный атрибут на хосте.
Стилизация слотированного содержимого с помощью ::slotted()
Содержимое, которое пользователь передаёт в компонент, находится в light DOM и отображается через <slot>. Такое содержимое по-прежнему принадлежит странице, поэтому её собственные стили имеют приоритет — однако вы всё равно можете обратиться к нему изнутри shadow root с помощью ::slotted().
Важное ограничение: ::slotted() совпадает только с верхнеуровневыми слотированными узлами, но не с их потомками. ::slotted(span) работает; ::slotted(div span) — нет.
<body>
<script>
class CustomButton extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
padding: 10px 20px;
background-color: #007bff;
color: #fff;
border: none;
cursor: pointer;
}
:host(:hover) {
background-color: #0056b3;
}
button {
font-weight: bold;
border: none;
background: none;
color: inherit;
cursor: inherit;
padding: 0;
}
/* Styling slotted content */
::slotted(span) {
font-style: italic;
text-decoration: underline;
}
</style>
<button>
<slot></slot>
</button>
`;
}
}
customElements.define('custom-button', CustomButton);
</script>
<!-- Test custom-button with slotted content -->
<custom-button id="my-button">Click <span>here</span></custom-button>
</body>Здесь ::slotted(span) нацелен на <span>, переданный как содержимое слота, делая его курсивным и подчёркнутым, тогда как окружающий текст "Click" остаётся нетронутым.
Разрешение странице настраивать компонент: CSS-переменные
Инкапсуляция — это отлично, но порой она ощущается как стена: хост-страница не может проникнуть внутрь, чтобы изменить цвет кнопки. Предусмотренный выход — CSS-переменные (пользовательские свойства) — единственное, что пронизывает shadow-границу. Компонент читает переменную через var() и задаёт запасное значение; страница устанавливает эту переменную снаружи.
<style>
/* The page customizes the component from outside the boundary */
theme-button {
--btn-bg: #28a745;
--btn-bg-hover: #1e7e34;
}
</style>
<theme-button>Save</theme-button>
<script>
class ThemeButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host {
/* var(--name, fallback): fallback is used if the page sets nothing */
background: var(--btn-bg, #007bff);
color: #fff;
padding: 8px 16px;
display: inline-block;
cursor: pointer;
}
:host(:hover) {
background: var(--btn-bg-hover, #0056b3);
}
</style>
<slot></slot>
`;
}
}
customElements.define('theme-button', ThemeButton);
</script>Кнопка отображается зелёной, потому что страница задала --btn-bg. Удалите эти два объявления — и она вернётся к синему цвету (#007bff). Это самый чистый способ предоставить API темизации, сохраняя внутреннее устройство компонента приватным.
<style> или adoptedStyleSheets
Размещение тега <style> в innerHTML каждого экземпляра работает, но дублирует текст CSS для каждого компонента на странице и вынуждает браузер разбирать его заново. Для компонентов, создаваемых многократно, лучше разделить один разобранный CSSStyleSheet между несколькими roots с помощью adoptedStyleSheets.
<my-badge>New</my-badge>
<my-badge>Beta</my-badge>
<script>
// Parsed once, reused by every instance
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #007bff;
color: #fff;
font-size: 12px;
}
`);
class MyBadge extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.adoptedStyleSheets = [sheet]; // adopt the shared sheet
root.innerHTML = `<slot></slot>`;
}
}
customElements.define('my-badge', MyBadge);
</script>Когда что использовать:
<style>внутри root — простейший вариант, без лишнего кода, подходит для разовых компонентов или небольших демонстраций.adoptedStyleSheets— предпочтителен, когда один и тот же компонент используется многократно: одна общая конструируемая таблица стилей означает меньший расход памяти и более быструю инициализацию. Кроме того, таблицу можно обновлять во время выполнения (sheet.replaceSync(...)), и все принявшие её roots немедленно отразят изменения.
Заключение
Стилизация Shadow DOM строится на нескольких идеях: стили инкапсулируются в обоих направлениях, к хосту обращаются через :host / :host() / :host-context(), к проецируемому содержимому — через ::slotted(), темизация реализуется с помощью CSS-переменных, а CSS подключается либо встроенно через <style>, либо эффективно через adoptedStyleSheets. В совокупности это позволяет создавать компоненты, которые выглядят правильно в любом контексте и при этом остаются настраиваемыми на ваших условиях.
Чтобы продвинуться дальше, изучите Shadow DOM Slots & Composition о том, как собирается слотированное содержимое, и Web Components о том, как сочетать shadow roots с пользовательскими элементами и шаблонами.