Реализация Shadow DOM для инкапсуляции стилей компонентов
Shadow DOM — механизм браузера для создания изолированного поддерева DOM. Стили снаружи не проникают внутрь, стили изнутри не вытекают наружу. Это позволяет создавать компоненты, которые работают одинаково в любом CSS-окружении.
Как работает Shadow DOM
Document (Light DOM)
├── <header>
├── <main>
│ └── <my-widget> ← Custom Element
│ └── #shadow-root ← Shadow Root (граница инкапсуляции)
│ ├── <style> ← стили видны только здесь
│ └── <div class="widget">
│ └── <slot> ← проекция Light DOM контента
└── <footer>
Глобальный CSS div { color: red } не влияет на div внутри shadow-root. CSS из shadow-root не влияет на div снаружи.
Открытый и закрытый режимы
// mode: 'open' — доступ к shadowRoot извне (el.shadowRoot !== null)
const openShadow = element.attachShadow({ mode: 'open' })
// mode: 'closed' — shadowRoot === null снаружи
// Используется для максимальной инкапсуляции (нативные браузерные элементы)
const closedShadow = element.attachShadow({ mode: 'closed' })
В подавляющем большинстве кейсов используется mode: 'open' — инструменты разработчика, тестирующие утилиты и библиотеки управления формами (react-hook-form, формы браузера) требуют доступ к shadowRoot.
Стилизация изнутри
class StyledCard extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
shadow.innerHTML = `
<style>
/* :host — сам элемент <styled-card> */
:host {
display: block;
border-radius: 12px;
overflow: hidden;
}
/* :host() с условием */
:host([variant="dark"]) {
background: #1a1a1a;
color: #fff;
}
:host([variant="light"]) {
background: #fff;
color: #111;
box-shadow: 0 2px 20px rgba(0,0,0,0.08);
}
/* :host-context() — реакция на окружение */
:host-context(.dark-theme) {
background: #222;
}
.card-body {
padding: 24px;
}
.card-title {
font-size: 20px;
font-weight: 700;
margin: 0 0 12px;
}
/* ::slotted — стилизация спроецированного контента */
::slotted(p) {
margin: 0;
line-height: 1.6;
opacity: 0.7;
}
::slotted([slot="footer"]) {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid currentColor;
opacity: 0.2;
}
</style>
<div class="card-body">
<h3 class="card-title">
<slot name="title">Заголовок</slot>
</h3>
<slot></slot>
<slot name="footer"></slot>
</div>
`
}
}
CSS Custom Properties: мост через Shadow DOM
Единственный способ передать стили снаружи внутрь — через CSS-переменные. Они проникают сквозь Shadow DOM boundary.
/* Снаружи: задаём переменные */
styled-card {
--card-bg: #f8f9fa;
--card-radius: 16px;
--card-padding: 32px;
--card-title-color: #1a1a1a;
}
/* Внутри Shadow DOM: используем переменные с fallback */
:host {
background: var(--card-bg, #fff);
border-radius: var(--card-radius, 8px);
}
.card-body {
padding: var(--card-padding, 24px);
}
.card-title {
color: var(--card-title-color, inherit);
}
<!-- Разные экземпляры с разными стилями -->
<styled-card style="--card-bg: #1a0050; --card-title-color: #fff">
<span slot="title">Тёмная карточка</span>
<p>Контент</p>
</styled-card>
<styled-card style="--card-bg: #e8f5e9; --card-padding: 40px">
<span slot="title">Зелёная карточка</span>
<p>Контент</p>
</styled-card>
CSS Parts: точечная стилизация снаружи
part атрибут открывает конкретные элементы для внешних стилей через ::part():
shadow.innerHTML = `
<div class="wrapper" part="wrapper">
<button class="btn" part="button trigger">
<slot></slot>
</button>
<div class="dropdown" part="dropdown">
<slot name="items"></slot>
</div>
</div>
`
/* Снаружи — стилизуем только открытые части */
my-dropdown::part(button) {
background: #7000ff;
color: #fff;
border-radius: 8px;
}
my-dropdown::part(dropdown) {
background: #1a1a1a;
border: 1px solid #333;
}
/* hover на часть */
my-dropdown::part(button):hover {
background: #5500cc;
}
Adoptable Stylesheets (CSSStyleSheet API)
Для переиспользования стилей между несколькими Shadow DOM без дублирования строк:
// Создаём разделяемый stylesheet один раз
const sharedStyles = new CSSStyleSheet()
sharedStyles.replaceSync(`
:host { box-sizing: border-box; }
*, *::before, *::after { box-sizing: inherit; }
button {
cursor: pointer;
border: none;
font: inherit;
}
`)
const typographyStyles = new CSSStyleSheet()
typographyStyles.replaceSync(`
h1, h2, h3 { font-weight: 700; line-height: 1.2; margin: 0; }
p { line-height: 1.6; margin: 0 0 1em; }
`)
// Применяем в компонентах
class ComponentA extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
// Adoptable stylesheets — без дублирования CSS
shadow.adoptedStyleSheets = [sharedStyles, typographyStyles]
// Только компонент-специфичные стили
const localStyle = new CSSStyleSheet()
localStyle.replaceSync(`.wrapper { background: blue; }`)
shadow.adoptedStyleSheets.push(localStyle)
}
}
class ComponentB extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
shadow.adoptedStyleSheets = [sharedStyles] // только базовые стили
}
}
Shadow DOM и формы
Нативные элементы форм внутри Shadow DOM не участвуют в form.elements и не попадают в FormData. Решение через ElementInternals:
class CustomInput extends HTMLElement {
static get formAssociated() { return true }
private internals: ElementInternals
private shadow: ShadowRoot
constructor() {
super()
// formAssociated + ElementInternals позволяют участвовать в форме
this.internals = this.attachInternals()
this.shadow = this.attachShadow({ mode: 'open' })
}
connectedCallback() {
this.shadow.innerHTML = `
<style>
input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border-color, #d0d5dd);
border-radius: 8px;
font: inherit;
outline: none;
}
input:focus {
border-color: var(--focus-color, #7000ff);
box-shadow: 0 0 0 3px var(--focus-ring, rgba(112, 0, 255, 0.15));
}
</style>
<input type="text" />
`
const input = this.shadow.querySelector('input')!
input.addEventListener('input', () => {
// Синхронизируем значение с формой
this.internals.setFormValue(input.value)
this.internals.setValidity(
input.validity,
input.validationMessage,
input
)
})
}
// Браузер вызывает при reset формы
formResetCallback() {
const input = this.shadow.querySelector('input')
if (input) input.value = ''
this.internals.setFormValue('')
}
}
customElements.define('custom-input', CustomInput)
Сроки
Один компонент с Shadow DOM, Custom Properties и Parts — 1 день. Система из 5–8 компонентов с Adoptable Stylesheets, ElementInternals для форм и документацией — 1–2 недели.







