Реалізація 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 тижні.







