Shadow DOM

Shadow DOM #

Shadow DOM — отдельное поддерево узлов, прикреплённое к хост-элементу через attachShadow. Стили и разметка внутри shadow root не смешиваются с остальной страницей: глобальный CSS не протекает внутрь, а внутренние селекторы не «выстреливают» наружу. Это основа инкапсуляции в Custom Elements.


attachShadow #

class DashboardTile extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' })

    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          border-radius: 8px;
          padding: 12px;
          background: var(--tile-bg, #1a1a2e);
        }
        h2 { margin: 0 0 8px; font-size: 1rem; }
        .metric { font-size: 1.5rem; font-weight: 600; }
      </style>
      <h2><slot name="title">Виджет</slot></h2>
      <p class="metric"><slot name="value">—</slot></p>
    `
  }
}

customElements.define('dashboard-tile', DashboardTile)
<dashboard-tile>
  <span slot="title">Активные заказы</span>
  <span slot="value">128</span>
</dashboard-tile>

mode: 'open' даёт доступ к shadow root через element.shadowRoot. В closed снаружи root не виден — только для жёсткой инкапсуляции; отладка сложнее.


Граница light DOM и shadow DOM #

Light DOMShadow DOM
Где задаётсяДочерние узлы в HTML страницыВнутри attachShadow
СтилиГлобальные таблицы CSSЛокальные <style> в shadow
Доступ из JSelement.childrenelement.shadowRoot.querySelector

Узлы в light DOM не отображаются напрямую, если в shadow есть <slot> — браузер проецирует их в место слота. Подробнее — в template и slots.


Псевдокласс :host #

:host стилизует сам хост-элемент изнутри shadow:

:host {
  display: inline-flex;
  gap: 8px;
}

:host([variant='compact']) {
  padding: 4px 8px;
  font-size: 0.875rem;
}

:host-context(.dark-theme) реагирует на предка снаружи shadow (поддержка в Chromium; в других движках проверяйте актуальность).


CSS custom properties (пробивание стилей) #

Жёсткая изоляция не запрещает темизацию через переменные — они проходят через границу:

shadow.innerHTML = `
  <style>
    button {
      background: var(--accent, #6366f1);
      color: var(--on-accent, #fff);
    }
  </style>
  <button><slot></slot></button>
`
<playlist-action style="--accent: #22c55e">Добавить в плейлист</playlist-action>

Так родитель задаёт палитру, не ломая внутреннюю вёрстку компонента.


События и compose #

События из shadow DOM по умолчанию не всплывают за пределы shadow (composed: false). Чтобы родитель поймал клик по внутренней кнопке, переотправьте событие или используйте composed: true:

this.shadowRoot.querySelector('button').addEventListener('click', () => {
  this.dispatchEvent(
    new CustomEvent('tile-action', { bubbles: true, composed: true })
  )
})

Слушайте на хосте: tile.addEventListener('tile-action', handler).


Shadow DOM ≠ «Shadow DOM v0» #

Нативные элементы вроде <video> и <input type="date"> уже используют внутренний shadow tree — это user-agent shadow. Ваш код создаёт author shadow через attachShadow. Вызов attachShadow дважды на одном элементе невозможен: повторный connectedCallback должен проверять наличие root.

connectedCallback() {
  if (this.shadowRoot) return
  this.attachShadow({ mode: 'open' })
  // ...
}

Итог #

  • attachShadow({ mode: 'open' | 'closed' }) создаёт изолированное поддерево.
  • Стили в shadow не конфликтуют с глобальными; :host оформляет сам элемент.
  • Темизация снаружи — через CSS-переменные на хосте.
  • Light DOM-дети попадают в shadow через <slot>.
  • События наружу — с composed: true или кастомные события на хосте.

См. также #