Shadow DOMは、ある要素の内側に「外のCSSやDOM操作から隔離された小さなDOMツリー」を作るWeb標準の仕組みです。ページ全体で共有される通常のDOMと違い、Shadow DOMの中に書いたスタイルは外へ漏れず、外側のスタイルも中へ入り込みません。この記事で扱うのは、shadowRootの作り方、openとclosedモードの違い、:hostなどによるスタイル設計、そして宣言型Shadow DOMやReact・自動テストで詰まりやすい点です。いずれもコードを添えて具体的に見ていきます。
まとめ:Shadow DOMの要点
- 正体:要素(シャドウホスト)に付ける、隔離されたDOMツリー。
element.attachShadow()で生成し、境界の内外でスタイルとID空間が分離される。 - 解決する課題:グローバルCSSの衝突、ID・クラス名の重複、外部スクリプトによる内部要素の書き換え。再利用可能なUI部品を安全に配布できる。
- openとclosed:
openはホストのshadowRootから中を参照でき、closedはnullを返す。ただしclosedはセキュリティ境界ではない(迂回可能)。 - スタイル:
:host・::slotted()・::part()とスロットで、境界をまたぐ最小限のスタイルだけを許可する設計になっている。 - いま押さえる新機能:JavaScript不要でサーバー側から影DOMを描ける宣言型Shadow DOM(
shadowrootmode属性)が主要ブラウザで利用可能になった。
以降で、定義から実装、モードの違い、スタイル設計、宣言型、フレームワークとテストの注意点まで順に見ていきます。
Shadow DOMとは:通常のDOMから分離される小さなツリー
Shadow DOMは、任意の要素にぶら下げる独立したDOMツリーです。ツリーを付ける元の要素をシャドウホスト、生成されたツリーの根をシャドウルート(shadowRoot)、その配下の要素群をシャドウツリーと呼びます。ホストより外にある通常のツリーはライトDOMと呼び分けます。
重要なのは境界の性質です。MDNは「ページのCSSは、シャドウDOM内のノードには影響しません」と明記しており、ホスト外のセレクタは境界を越えて中の要素に当たりません。逆に、シャドウツリー内で定義したスタイルも外へ漏れません。ID・クラス名も別空間になるため、同じidを持つ要素がページ内に複数あっても衝突しません。
この分離が効くのは、ボタンやカード、日付ピッカーのようなどのページに置いても見た目と挙動が壊れてはいけない再利用部品です。ブラウザ標準のvideo要素の再生コントロールも内部はShadow DOMで作られており、ページ側のCSSで勝手に崩れないようになっています。通常のDOMだけで同じことをやると、グローバルなCSSやスクリプトが部品の内部に干渉し、名前空間の管理コストが増えていきます。Shadow DOMはその干渉を言語仕様のレベルで断ち切る手段です。
shadowRootの作り方:attachShadowと基本実装
シャドウルートはElement.attachShadow()で作ります。引数のオブジェクトでmodeを指定し、戻り値のShadowRootに対してinnerHTMLやDOM APIで中身を組み立てます。中にstyleを書けば、そのスタイルはこのツリー内だけに適用されます。
const host = document.querySelector('#widget');
const root = host.attachShadow({ mode: 'open' });
root.innerHTML =
'<style>p { color: crimson; }</style>' +
'<p>この段落はシャドウツリー内なので外のCSSに影響されない</p>';
ホスト側から中を触るときはhost.shadowRoot経由でアクセスします。なお、すべての要素にシャドウルートを付けられるわけではなく、div・spanやHTMLElementを継承したカスタム要素など、仕様で許可された要素に限られます。許可されない要素でattachShadow()を呼ぶと例外になります。多くの実装では、後述するカスタム要素のconstructorやconnectedCallbackの中でシャドウルートを生成します。
openとclosedモードの違いと選び方
attachShadow()のmodeにはopenとclosedの2つがあります。両者の実務的な違いは「ホストのshadowRootプロパティから中を参照できるか」の一点です。
const a = el1.attachShadow({ mode: 'open' });
el1.shadowRoot; // ShadowRoot を返す(外から参照できる)
const b = el2.attachShadow({ mode: 'closed' });
el2.shadowRoot; // null を返す(プロパティ経由では参照させない)
| 観点 | open | closed |
|---|---|---|
| host.shadowRoot | ShadowRootを返す | nullを返す |
| 外部からの内部アクセス | 可能 | プロパティ経由は不可 |
| デバッグ・テストのしやすさ | しやすい | しにくい |
| 主な用途 | 一般的な部品・多くのライブラリの既定 | 内部を触らせたくない配布部品 |
closedはセキュリティ境界ではない
ここは誤解が多い点なので言い切ります。closedは「セキュリティの仕組み」ではありません。MDNも「強力なセキュリティの仕組みだと考えるべきではありません。例えば、ページで実行するブラウザー拡張機能など、回避する方法があるからです」と述べています。実際、attachShadowを呼ぶコード自身は戻り値のルートを保持できますし、Element.prototype.attachShadowを差し替えて参照を奪う手口も知られています。closedが伝えるのは「外部コードはこの内部に触れるべきでない」という意思表示であって、悪意ある操作やXSSを防ぐ壁ではありません。機密の保護やサニタイズはサーバー側やCSP、入力のエスケープで別途行う前提です。既定はopenにしておき、デバッグや外部連携で困る場面が少ないのが実務では扱いやすい選択です。
Shadow DOM内のスタイル設計::host・::slotted・::part
境界で完全に分離すると、今度は「ホスト自身を装飾したい」「利用者側から一部だけスタイルを上書きさせたい」といった、境界をまたぐ調整が必要になります。Shadow DOMはこれを無制限に開放せず、専用のセレクタで限定的に許可します。
| セレクタ | 対象 | 用途 |
|---|---|---|
| :host | シャドウホスト自身 | 部品の外枠(表示形式・余白)の既定を定義 |
| :host(.selected) | 条件付きのホスト | ホストの状態に応じた見た目の切替 |
| ::slotted(h2) | スロットに差し込まれた要素 | 利用者がライトDOMに置いた要素へ限定的に適用 |
| ::part(label) | part属性を付けた内部要素 | 利用者側CSSからの上書きを明示的に許可 |
:host { display: block; }
::slotted(h2) { margin: 0; }
button::part(label) { font-weight: bold; }
スタイルの読み込み方は主に3通りです。シャドウルート内に直接style要素を書く方法、外部CSSをlinkで読む方法、そして同じスタイルを複数のシャドウルートで共有する構築可能なスタイルシート(Constructable Stylesheets)です。後者はnew CSSStyleSheet()で作ったシートを各ルートのadoptedStyleSheetsに割り当てるもので、同一CSSを何十個の部品に重複させず1つの実体で共有できるため、大量のコンポーネントを持つ設計でメモリと初期化コストを抑えられます。
スタイルのスコープ分離という目的だけなら、ビルド時にクラス名をハッシュ化するCSSフレームワークとCSS Modulesの違いと選択基準のようなアプローチとも比較になります。CSS Modulesはビルドツール前提でスタイルだけを局所化する手法、Shadow DOMはブラウザ標準でDOM構造ごと隔離する手法、という住み分けです。フレームワーク非依存で部品を配りたいならShadow DOM、既存のReact/Next.js構成でスタイル衝突だけ防ぎたいならCSS Modules、と目的で選び分けると判断がぶれません。
Web Componentsとの関係:カスタム要素とスロット
Shadow DOMは単体でも使えますが、本来の力を発揮するのはWeb Componentsの一部として使うときです。Web Componentsは、独自タグを定義するCustom Elements、再利用するマークアップを保持するtemplate、そしてShadow DOMの3つの標準の組み合わせを指します。Custom Elementsがタグと振る舞いを、Shadow DOMが内部の隔離を、slotが「利用者が外から中身を差し込む口」を担います。
slotを置くと、利用者がホストタグの中に書いたライトDOMの要素が、シャドウツリーの指定位置に投影されます。中身は利用者側(ライトDOM)に属したまま、レイアウトだけShadow DOMが決める、という分業ができます。この3点を毎回手書きするのは煩雑なので、実務ではGoogleが開発した軽量ライブラリのLitがよく使われます。Litはlit-htmlとLitElementでWeb Componentsを作る使い方で扱っているとおり、Custom ElementsとShadow DOMを標準どおり使いながら、リアクティブなプロパティやテンプレート更新の定型処理を数KB規模で肩代わりします。
宣言型Shadow DOM(Declarative Shadow DOM)とSSR対応
従来のShadow DOMはJavaScriptのattachShadow()でしか作れず、サーバーサイドレンダリング(SSR)と相性が悪いという弱点がありました。サーバーが返すHTMLだけでは影DOMを表現できず、JSが走るまで内部が空になるためです。これを解決するのが宣言型Shadow DOM(Declarative Shadow DOM)で、HTMLのtemplateにshadowrootmode属性を付けるだけで、JavaScriptなしにシャドウルートを構築できます。
<my-card>
<template shadowrootmode="open">
<style>::slotted(h2) { margin: 0; }</style>
<slot></slot>
</template>
<h2>ライトDOM側に置いた見出し</h2>
</my-card>
属性名は仕様更新で従来のshadowrootからshadowrootmodeへ改名された点に注意が必要です(値はopen/closed)。ブラウザ側でHTMLをパースした時点で影DOMが確定するため、初回表示が速く、JSが無効な環境やクローラにも内部が届きます。
| ブラウザ | 対応バージョン |
|---|---|
| Chrome / Edge | 111以降 |
| Safari | 16.4以降 |
| Firefox | 123以降 |
表の対応版は標準のshadowrootmode属性を指します。旧来の非標準shadowroot属性はChrome 90以降に存在しましたが、標準化の過程で名称が変わったため、現在はshadowrootmodeを使ってください。宣言型Shadow DOMは2024年8月にBaselineの「Newly available」入りしており、主要ブラウザの新しめの版で使えます。ただし古い環境も対象にする場合はフォールバックが要るため、対応状況は公式のブラウザ互換情報で確認してください。SSRフレームワークでWeb Componentsを配信するなら、まず検討すべき機能です。
フレームワーク・テストでのShadow DOM:実務の落とし穴
Shadow DOMは万能ではありません。隔離という長所は、そのまま「境界をまたぐ操作が面倒になる」という短所の裏返しです。導入前に、フレームワーク連携とテストで詰まりやすい点を押さえておきます。
ReactやVueと組み合わせるときの摩擦
ReactやVueのコンポーネントは、それ自体が仮想DOMでスタイルと構造を管理するため、Shadow DOMのカプセル化と目的が一部重複します。フレームワーク内の画面部品にShadow DOMは通常不要です。使いどころは「複数フレームワークやプレーンHTMLに跨って配る共通UI」を作る場面に絞られます。実際に組み込むと、Reactの合成イベントがシャドウ境界で届きにくい、フォーム部品の値が親フォームに自動連携されない(ElementInternalsによる明示対応が必要)、といった手当てが増えます。カスタムイベントを境界の外へ伝えるにはcomposed: trueを指定する必要があり、これを忘れると親側でイベントが拾えません。「スタイル衝突を消したい」だけが動機なら、前掲のCSS Modulesなどスタイル局所化で足りることが多く、Shadow DOMまで持ち込む必要はありません。
自動テストで要素が取れない問題
E2Eテストでの最大のつまずきは、通常のセレクタがシャドウ境界を越えられないことです。document.querySelector()や多くのテストロケータは、シャドウルート内の要素を直接は掴めません。Seleniumなら、まずホスト要素を取得し、そのshadowRoot(getShadowRoot())を経由して内部を辿る必要があります。closedモードの部品はshadowRootがnullのため、この手順自体が使えずテスト難易度が跳ね上がります。テスト対象にShadow DOMを含むなら、Selenium GridとSelenium Serverの違いと適用シナリオのような実行基盤の話とは別に、ロケータ設計をシャドウ境界前提で組み、部品側はopenを選ぶのが無難です。
Shadow DOMを使うべきでない場面
次のいずれかに当てはまるなら、Shadow DOMの採用は見送るか慎重に検討すべきです。第一に、単一のReact/Vueアプリ内で完結するUIで、配布や多フレームワーク共存の要件が無い場合。第二に、機密情報の秘匿を目的にしている場合(前述のとおりclosedは防御にならない)。第三に、既存の自動テスト資産が大きく、シャドウ境界対応の改修コストに見合うメリットが無い場合。逆に、デザインシステムの部品配布やサードパーティに埋め込むウィジェットなど、環境を選ばず崩れない部品が要る場面では、Shadow DOMの隔離が明確に効きます。他社のコードを別オリジンで走らせる必要がある場合はShadow DOMでは足りないため、iframeを採用してよい条件と別手段への切り替えと併せて判断してください。
よくある質問
Shadow DOMと仮想DOMは何が違いますか?
別物です。仮想DOMはReactなどが差分更新を効率化するために持つ「メモリ上のDOMの写し」で、性能最適化の仕組みです。Shadow DOMはブラウザ標準の機能で、実DOMの一部を外部から隔離するカプセル化の仕組みです。目的(描画効率 対 分離)もレイヤ(ライブラリ内部 対 ブラウザ標準)も異なります。
openとclosedはどちらを使うべきですか?
特別な理由が無ければopenを推奨します。closedにしてもセキュリティ上の防御にはならず、デバッグ・テスト・外部連携が難しくなるだけだからです。多くの主要ライブラリも既定はopenです。
Shadow DOMはSEOやクローラに不利ですか?
スロットに差し込むライトDOMのコンテンツはHTML上に存在するためクロールされます。一方、JavaScriptで生成する影DOM内のテキストはレンダリング依存になります。SEO上重要な本文は宣言型Shadow DOMやライトDOM側に置くと、初期HTMLへ確実に含められます。
shadowRootの中身は外部のCSSで一切変えられないのですか?
原則として外部セレクタは境界を越えませんが、部品側が::part()で公開した要素や、ホスト側から継承されるcolor・fontなどの継承プロパティは、境界を越えて影ツリー内の要素にも及びます。つまり「何を上書きさせるか」を部品の作り手が設計で決められます。
宣言型Shadow DOMは古いブラウザでも動きますか?
Chrome/Edge 111、Safari 16.4、Firefox 123以降で利用でき、2024年8月にBaseline入りしています。それ以前の版では機能しないため、広い互換性が必要ならJavaScriptによる生成へのフォールバックを用意します。