Shadow DOMは、ある要素の内側に、ページ本体から隔離された別のDOMツリーを付けるWeb標準の仕組みです。中に書いたCSSは外へ漏れず、ページのCSSセレクタやdocument.querySelector()も中には届きません。この記事では、attachShadow()の書き方、openとclosedの違い、slotでの差し込み、境界を越えるCSSとイベント、HTMLだけで書ける宣言型Shadow DOMまでを、Chromium 156(ヘッドレス)で挙動を確かめたコード付きで説明します。
まとめ:Shadow DOMの要点
- 正体:要素(シャドウホスト)に付ける隔離されたDOMツリー。
element.attachShadow({ mode: 'open' })で作り、CSSのセレクタとIDの空間が内外で分かれる。 - openとclosed:主な違いは
host.shadowRootからの参照可否と、外側のリスナーから見たイベント経路。closedはセキュリティ境界ではないので、迷ったらopen。 - 境界を越えるもの:
colorやfont-sizeなどの継承プロパティ、CSSカスタムプロパティ(CSS変数)、::part()で公開した要素、clickなどの標準UIイベント。自作のCustomEventはcomposed: trueを付けないと外へ出ない。 - 宣言型Shadow DOM:
<template shadowrootmode="open">でJavaScriptなしにシャドウルートを作れる。2024年2月20日にBaseline Newly available、2026年8月20日にWidely availableになった。ただしinnerHTMLで流し込んでも有効にならない。 - 使わない方がよい場面:単一のReact/Vueアプリ内で完結するUI、機密の秘匿が目的の場合、既存のE2Eテスト資産が大きい場合。
以下、仕組み→作り方→open/closed→slot→CSS→イベント→宣言型→採否の判断の順に見ていきます。
Shadow DOMの仕組み:シャドウホスト・シャドウルート・ライトDOM
シャドウツリーを付ける元の要素をシャドウホスト、付いたツリーの根をシャドウルート(JavaScriptではShadowRootオブジェクト)、ホストの子として利用側が記述する通常のDOMを、その部品のライトDOMと呼びます。ホストとシャドウルートの間がシャドウ境界で、CSSのセレクタとDOMの検索はこの境界で止まります。
MDNの「シャドウDOMの使用」は、ページのCSSがシャドウDOM内のノードに影響しないことを、カプセル化の中心として説明しています。逆向きも同じで、シャドウツリー内のp { color: red; }はページ本体のpには当たりません。IDも別空間なので、同じid="title"の要素がページとシャドウツリーの両方にあっても衝突しません。
身近な例はブラウザ組み込みの部品です。<video controls>の再生ボタンや<input type="range">のつまみは、ブラウザが内部にShadow DOMで作っています。ページ側のCSSで再生ボタンが崩れないのはこの隔離のおかげです。
Shadow DOMは、独自タグを定義するCustom Elements、使い回すマークアップを持つ<template>と並ぶ、Web Componentsを構成する標準の1つです。単体でも使えますが、実務ではカスタム要素の内部実装として使う形が大半です。
attachShadowでシャドウルートを作る手順と付けられる要素
シャドウルートはElement.attachShadow()で作ります。戻り値のShadowRootにinnerHTMLやDOM APIで中身を入れ、そこに書いた<style>はこのツリーの中だけに効きます。
<div id="host"></div>
<script>
const host = document.querySelector('#host');
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>p { color: crimson; }</style>
<p id="in">シャドウツリー内の段落</p>`;
document.querySelector('#in'); // null(ページ側の検索は境界を越えない)
root.querySelector('#in'); // <p id="in">(ShadowRoot から検索すれば取れる)
</script>
カスタム要素で使う場合は、constructorの中でthis.attachShadow()を呼ぶのが定番です。1つの要素に付けられるシャドウルートは1つだけで、同じ要素に2回目を呼ぶとNotSupportedErrorになります(Chromium 156で確認)。
attachShadowの対象:標準HTML要素18種類とカスタム要素
DOM Standardのattachshadowの定義は、シャドウホストになれる要素を、有効なカスタム要素名を持つ要素と、article・aside・blockquote・body・div・footer・h1〜h6・header・main・nav・p・section・spanに限っています。Chromium 156で試すと、img・a・input・button・textarea・videoはどれもNotSupportedErrorでした。ボタンやリンクの見た目を部品化したいときは、buttonそのものにではなく、カスタム要素のシャドウツリーの中にbuttonを置きます。
attachShadowの主なオプション6つ
| オプション | 既定値 | 効果 |
|---|---|---|
| mode | (必須) | open/closed。shadowRootから参照できるか |
| delegatesFocus | false | ホストへのフォーカスを内部のフォーカス可能な要素へ渡す |
| slotAssignment | named | manualにするとassign()で手動割り当て |
| clonable | false | cloneNode()でシャドウルートも複製する |
| serializable | false | getHTML()で書き出せるようにする |
| customElementRegistry | 文書のレジストリ | このルート専用のタグ名の登録先 |
実務で最初に効いてくるのはdelegatesFocusです。ラベルとinputを内部に持つ入力部品でtrueにすると、ホストにfocus()したときに内部のinputへフォーカスが移り、ホスト自身も:focusに一致します。Chromium 156では、document.activeElementはホスト、shadowRoot.activeElementは内部のinputを指しました。外からはホストまでしか見えず、内部の実際の要素はシャドウルート側で引く、という関係です。
customElementRegistryは、シャドウルートごとに別のCustomElementRegistryを持たせるScoped Custom Element Registriesのオプションです。同じmy-buttonというタグ名を、バージョンの違う2つの部品ライブラリが1ページの中でそれぞれ定義できるようになります。Chrome/Edge 146、Safari 26に続き、2026年10月27日公開のFirefox 159のリリースノートで対応が告知され、主要3エンジンがそろいました。古い版の利用者が残るうちは、タグ名の衝突を避ける命名(接頭辞)と併用してください。
openとclosedの違い:参照可否とイベント経路
modeの違いは、ホストのshadowRootプロパティからの参照可否と、外側から見えるイベント経路に現れます。CSSの隔離は共通ですが、closed内部のノードは外側のリスナーのcomposedPath()に含まれません。
const a = el1.attachShadow({ mode: 'open' });
el1.shadowRoot; // ShadowRoot
const b = el2.attachShadow({ mode: 'closed' });
el2.shadowRoot; // null(戻り値の b を持っているコードだけが中を触れる)
| 観点 | open | closed |
|---|---|---|
| host.shadowRoot | ShadowRoot | null |
| ページのCSSが中に届くか | 届かない | 届かない |
| Playwrightのロケータ | 貫通する | 貫通しない |
| 外で見るevent.composedPath() | 内部ノードを含む | ホストから始まる |
| 主な用途 | 一般的な部品(Litなどの既定) | 内部を触らせない意思表示 |
composedPath()の行は、closedの中のボタンをクリックしたとき、documentのリスナーが受け取る経路がホスト・body・htmlの順に始まり、内部のボタンが見えないことを示しています(Chromium 156で確認)。
closedのアクセス制約とセキュリティ上の限界
MDNは、closedを強力なセキュリティの仕組みと考えるべきではなく、ページで動くブラウザー拡張機能などで回避できると明記しています。Firefoxの拡張機能APIには、closedでもシャドウルートを取り出せるopenOrClosedShadowRoot()まで用意されています。ページ内のスクリプトであっても、部品より先にElement.prototype.attachShadowを差し替えれば戻り値を横取りできます。closedが伝えるのは「外のコードはここを触らないでほしい」という意思表示であり、XSSや情報の持ち出しを防ぐ壁ではありません。秘匿したい値をシャドウツリーに置いても守れないので、閲覧させたくない情報はそもそもクライアントへ送らないでください。
web.devのShadow DOM解説もclosedを「避けるべき」としています。理由は単純で、テストツールや利用者側のスクリプトが中に入れなくなるだけで、守れるものが無いからです。既定はopenにしてください。
DevToolsの#shadow-root (open)/(closed)表示の読み方
Chrome DevToolsのElementsパネルでは、シャドウホストの直下に#shadow-root (open)または#shadow-root (closed)という行が出ます。この行は、実在するShadowRootノードをDevToolsが表示したものです。HTMLタグではありませんが、DOM上ではDocumentFragmentを継承するノードです。closedでもDevToolsからは中身を展開して見られます。DevToolsはページのスクリプトより強い権限で動いているためで、ここからも「closedは隠蔽にならない」ことが分かります。
videoやinputの内部(ブラウザが作るシャドウツリー)は、既定では表示されません。DevToolsの設定のPreferencesにある「Show user agent shadow DOM」をオンにすると、#shadow-root (user-agent)として見えるようになります。Elementsパネルで要素を選ぶと、Consoleで$0.shadowRootと打てばopenのルートを取り出せます。
slotでライトDOMを差し込む:名前付きslot・既定の中身・slotchange
<slot>は、利用者がホストのタグの中に書いた要素(ライトDOM)を、シャドウツリーの指定位置に表示する差し込み口です。差し込まれた要素はライトDOMに属したままで、表示位置だけがシャドウツリー側で決まります。そのためページのCSSは、差し込まれた要素には今まで通り当たります。
<!-- 利用側(ライトDOM) -->
<my-card>
<span slot="title">見出し</span>
<span>本文</span>
</my-card>
<!-- my-card のシャドウツリー -->
<slot name="title"></slot>
<slot>本文が無いときの既定の中身</slot>
slot="title"を付けた要素はname="title"のslotへ、属性の無い要素とテキストは名前の無いslotへ入ります。割り当てられるノードが無いときは、<slot>タグの中身が既定の表示になります。ただし、名前の無いslotには改行や空白のテキストノードも割り当てられるため、見た目が空でも既定の中身が表示されない場合があります。JavaScriptからはslot.assignedElements()で割り当て済みの要素を取得できます。後からホストに子要素を足すと、該当するslotでslotchangeイベントが発火します。上の構成でChromium 156で試すと、名前付きslotには「見出し」、既定のslotには「本文」が割り当てられ、要素の追加でslotchangeが発火しました。
属性名で振り分けたくない場合は、attachShadow({ mode: 'open', slotAssignment: 'manual' })にして、slot.assign(node)で割り当てを明示できます。タブ部品のように、選ばれた1枚だけを表示するslotに入れ替える用途向けです。
Shadow DOMのCSS設計:境界を越えるもの・越えないもの
境界でCSSを完全に遮断すると、テーマの色を揃えたい・利用者に一部だけ上書きさせたい、といった調整ができません。境界をまたぐスタイルの仕組みには、次の4つのほか、後述する::slotted()などがあります。
| 仕組み | 越える方向 | 用途 |
|---|---|---|
| 継承プロパティ | 外→中 | color・font-sizeなどを親から受け継ぐ |
| CSSカスタムプロパティ | 外→中 | 部品が受け付けるテーマ値の口 |
| ::part() | 外→中 | part属性を付けた内部要素だけ上書き可 |
| :host | 中→ホスト | 部品の外枠の既定スタイル |
継承プロパティとCSS変数の境界越え
ページ側でbody { font-size: 20px; --accent: red; }とし、シャドウツリー内でp { color: var(--accent); }と書くと、内部のpは20pxの赤になります(Chromium 156でgetComputedStyleの値を確認)。セレクタは越えませんが、継承する値と変数は越えるということです。部品を作る側は、色や余白をvar(--card-bg, white)のように変数+既定値で受け付けておくと、利用者は部品の内部構造を知らずにテーマを合わせられます。通常の継承プロパティを初期値に戻すには、:host { all: initial; }を使えます。ただし、CSSカスタムプロパティとdirection・unicode-bidiはリセット対象外です。
:host・::slotted・::partの使い分け
/* シャドウツリー内のCSS */
:host { display: block; border: 1px solid #ddd; }
:host([disabled]) { opacity: .5; } /* ホストの属性で切り替え */
::slotted(span) { font-weight: bold; } /* 差し込まれた要素の最上位だけ */
/* ページ側のCSS(内部で <button part="label"> と公開済み) */
my-card::part(label) { color: navy; }
:hostとページ側の宣言が競合すると、通常宣言同士ではページ側が優先されます。ただし、双方が!importantの場合はシャドウツリー側が優先されます。::slotted()が選べるのは差し込まれた要素そのものだけで、その子孫(::slotted(div p)のような指定)は選べません。::part()は部品の作り手がpart属性で公開した要素だけを外から指定させる口で、2020年7月からBaseline(主要ブラウザで利用可)です。上書きされてよい箇所をpartとCSS変数で絞って公開するのが、壊れにくい部品の作り方です。
adoptedStyleSheetsによるスタイルシートの共有
同じ部品を100個置くと、各シャドウツリーに<style>を配置する構成では100個のstyle要素ができます。ただし、同一内容の内部スタイルシートをブラウザが共有する最適化もあり、解析回数が必ず100回になるわけではありません。new CSSStyleSheet()で作ったシートを各ルートのadoptedStyleSheetsに割り当てれば、1つのシートを全ルートで共有できます(Constructable Stylesheets)。Safari 16.4の対応で2023年3月27日にBaseline入りしています。
const sheet = new CSSStyleSheet();
sheet.replaceSync(':host { display: block; }');
class MyCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).adoptedStyleSheets = [sheet];
}
}
customElements.define('my-card', MyCard);
スタイルの衝突を防ぎたいだけなら、ビルド時にクラス名を一意化するCSS Modulesでも足ります。CSS Modulesはビルドツールとフレームワークの中で完結する方法、Shadow DOMはビルドに依存せずDOMごと隔離する方法です。配布先のフレームワークが決まっていない部品ならShadow DOM、自社のNext.jsアプリの中だけならCSS Modules、と配布範囲で選ぶと迷いません。
イベントの境界越え:composedとリターゲティング
シャドウツリー内で起きたイベントを外のリスナーが受け取ると、event.targetは内部の要素ではなくホストに置き換わります。これをリターゲティングと呼びます。内部構造を外に見せないための仕組みです。
境界を越えるかどうかは、イベントのcomposedフラグで決まります。click・input・keydownなどブラウザが発火する標準のUIイベントの多くはcomposed: trueで、外まで届きます。一方、自分で作るCustomEventは既定がcomposed: falseで、bubbles: trueを付けてもシャドウルートで止まります。
// 以下は、openのシャドウルート内にbuttonを持つカスタム要素のインスタンスメソッド内で実行
const btn = this.shadowRoot.querySelector('button');
// 外の document では受け取れない(composed の既定は false)
btn.dispatchEvent(new CustomEvent('card-select', { bubbles: true }));
// 外まで届く。document 側で見る e.target はホスト要素
btn.dispatchEvent(new CustomEvent('card-select', {
bubbles: true, composed: true, detail: { id: 42 }
}));
Chromium 156で、内部のボタンから上の2種類を発火させdocumentで数えると、前者は0回、後者は1回でした。内部のbuttonをclick()したときも、documentのリスナーが見たe.targetはホストのidでした。部品の利用者が「どのボタンが押されたか」を知る必要があるなら、e.targetではなくdetailに必要な値を入れて渡します。内部の経路まで必要な場面ではe.composedPath()を使いますが、前述のとおりclosedの内部はこの経路にも出ません。
宣言型Shadow DOM:shadowrootmodeによるSSR
attachShadow()はJavaScriptが動くまで実行されないため、サーバーで描画したHTMLにシャドウツリーを含められず、表示がJavaScriptの実行待ちになるのが弱点でした。宣言型Shadow DOMは、<template>にshadowrootmode属性を付けるだけで、HTMLのパース時に親要素へシャドウルートを付けます。
<my-card>
<template shadowrootmode="open">
<style>:host { display: block; }</style>
<slot></slot>
</template>
<h2>ライトDOM側の見出し</h2>
</my-card>
属性値はopenかclosedです。delegatesFocusなどのオプションには、shadowrootdelegatesfocus・shadowrootclonable・shadowrootserializableの各属性が対応します。この3属性はshadowrootmodeより後に追加されたもので、Safariは18から対応しています。古い記事にあるshadowroot属性(modeを付けない名前)はChromeだけの旧仕様で、Chrome 119での削除が告知されています。新しく書くコードではshadowrootmodeだけを使います。
宣言型Shadow DOMの対応ブラウザとBaseline
| ブラウザ | shadowrootmode対応 |
|---|---|
| Chrome/Edge | 111以降 |
| Safari(iOS含む) | 16.4以降 |
| Firefox | 123以降 |
web-featuresのデータ(3.44.0)では、Firefox 123の公開日である2024年2月20日にBaseline Newly available、その30か月後の2026年8月20日にWidely availableになっています。Widely availableは「主要ブラウザで2年半以上使える」という目安なので、2026年10月時点では採用しやすい段階です。フォールバックを省くかは、対象ブラウザと実際の利用者の対応状況を確認して決めます。
宣言型Shadow DOMの挿入APIとinnerHTMLの制約
宣言型Shadow DOMが有効になるのは、ページの初回パースと、明示的に許可したAPIだけです。Chromium 156で同じHTML文字列を流し込んで比べた結果が次の表です。
| 流し込み方 | シャドウルート |
|---|---|
| element.innerHTML | 付かない(templateのまま残る) |
| new DOMParser().parseFromString() | 付かない |
| element.setHTMLUnsafe() | 付く |
| Document.parseHTMLUnsafe() | 付く |
fetchで取ったHTML断片を差し込む構成(htmxやTurboのような部分更新)で宣言型を使うなら、setHTMLUnsafe()で入れます。サニタイズ付きのsetHTML()(Sanitizer API)はChromium 156で同じ文字列を渡すと、既定の設定ではmy-cardごと取り除かれ、シャドウルートは付きませんでした。setHTMLUnsafe()は既定ではサニタイズしませんが、対応環境ではsanitizerオプションを指定できます。未処理の利用者入力は渡さないでください。parseHTMLUnsafe()はSafari 26の対応で2025年9月15日にBaseline Newly availableになりましたが、Safari 17.4〜18系の対応は部分的です。逆方向の書き出しはelement.getHTML({ serializableShadowRoots: true })で、serializableを付けたシャドウルートだけが<template shadowrootmode>として出力されます。付けていないルートと、引数なしのgetHTML()ではシャドウツリーは出力されません。getHTML()はSafari 18の対応で2024年9月16日にBaseline Newly availableです。
Litでは@lit-labs/ssrがこの宣言型の形でHTMLを出力します。SSRの実装はLitとは?lit-htmlとLitElementの違い・使い方とSSR・React連携で扱っています。
Shadow DOMを使う場面・使わない場面の判断
部品ライブラリの設定に「Shadow DOMを使う/使わない」の切り替えがあることからも分かるように、隔離は常に得ではありません。隔離によって、ページ側からの上書き・フォーム連携・アクセシビリティ・テストのすべてで、境界を越える手当てが要るようになります。
ReactやVueと組み合わせるときの摩擦
単一のReact/Vueアプリの中の画面部品なら、Shadow DOMは要りません。スタイルの衝突はCSS ModulesやCSS-in-JSで防げ、境界の手当てだけが増えます。使う意味があるのは、複数のフレームワークやCMSのページに同じ部品を配る場合と、他社サイトに埋め込むウィジェットのように、置き先のCSSを制御できない場合です。複数チームの画面を1ページに組み合わせる構成なら、マイクロフロントエンドの統合方式の比較と合わせて判断してください。
React 19からは、カスタム要素のインスタンスに存在するプロパティ名のpropsはプロパティとして渡され、カスタムイベントもon接頭辞のJSXで購読できるようになりました(React 19の公式リリース告知で、カスタム要素への完全対応が明記されています)。React 18以前では、ref経由でプロパティを入れ、addEventListenerで購読する手書きが必要です。版ごとの違いはReactの最新バージョンとReact 19の新機能を参照してください。
フォームとアクセシビリティ:ElementInternalsとIDREFの壁
シャドウツリー内の<input>は、外側の<form>の送信値に自動では含まれません。カスタム要素にstatic formAssociated = trueを宣言し、this.attachInternals()で得たElementInternalsのsetFormValue()で値を渡します。この仕組みもSafari 16.4の対応で2023年3月27日にBaseline入りしています。
見落とされやすいのが、aria-labelledbyや<label for>のようなIDによる参照が境界を越えないことです。ページ側の<label for="email">は、シャドウツリー内のid="email"を指せません。これを解決するreferenceTargetはChrome 152(2026年8月25日公開)とEdge 152(2026年8月28日公開)で既定で有効になりましたが、Firefox 144とSafari 26は設定フラグが要る実験実装にとどまり、DOM Standardへの追加もまだ議論中です(2026年10月時点)。それまでは、ラベルも入力欄もシャドウツリーの内側に入れて、部品の中で参照を完結させます。
自動テストで要素を取る方法:Playwright・Cypress・Selenium
E2Eテストでの扱いはツールで分かれます。
| ツール | open | closed | 書き方 |
|---|---|---|---|
| Playwright | 既定で貫通 | 不可 | 通常のロケータ(XPathは貫通しない) |
| Cypress | 設定で貫通 | 不可 | includeShadowDom: true |
| Selenium 4 | 手動で辿る | WebDriver経由で取得可能(ドライバー対応による) | getShadowRoot()から検索 |
Playwright 1.64.1で試すと、CSSロケータのpage.locator('#in')とgetByText()はopenのシャドウツリー内の要素を1件見つけ、XPathの//p[@id="in"]とclosedの内部は0件でした。Seleniumではdriver.findElement(By.css("my-card")).getShadowRoot()でシャドウルートを取り、そこからCSSセレクタで探します。PlaywrightやCypressの通常の検索で内部要素を扱うならopenを選びます。SeleniumのWebDriver経由ではclosedも取得対象になるため、使用するブラウザとドライバーで確認してください。ページを部品単位のクラスにまとめて保守する方法はPage Object Model(POM)の実装例、Cypressの設定はCypress E2Eテストの使い方にあります。
Shadow DOMを使うべきでない条件
次のどれかに当てはまるなら、Shadow DOMは採用しません。
- 部品が単一のReact/Vue/Svelteアプリの中だけで使われ、他の環境に配らない。
- 目的が値や処理の秘匿である(closedでも拡張機能やDevToolsから読める)。
- ページ側のCSSで部品の内部まで自由に上書きしたい要件がある(デザイナーが既存のグローバルCSSで調整する運用など)。
- 既存のSelenium資産が大きく、XPath中心のロケータを書き換える工数が取れない。
反対に、デザインシステムの部品を複数のプロダクトへ配る場合や、置き先のCSSを制御できない埋め込みウィジェットでは、隔離が直接効きます。デザインシステムの設計から部品の実装まで相談したい場合は、一創のUI/UXデザイン支援でも対応しています。なお、他社のスクリプトを安全に隔離したい(同じオリジンで実行させたくない)場合はShadow DOMでは足りません。オリジンごと分けるiframeのsandbox属性を使ってください。
よくある質問
Shadow DOMと仮想DOMは何が違いますか?
別物です。仮想DOMはReactなどのライブラリが差分更新のためにメモリ上に持つDOMの写しで、描画を効率化する仕組みです。Shadow DOMはブラウザ標準の機能で、実際のDOMの一部をCSSと検索から隔離する仕組みです。目的(描画効率か隔離か)も、実装の場所(ライブラリ内部かブラウザか)も違います。両者は排他的でもなく、ReactでShadow DOMを持つカスタム要素を描画することもできます。
shadow root closedのとき、テストやスクリプトから中を触る方法はありますか?
ページのスクリプトからは、attachShadow()の戻り値を部品自身が外へ渡さない限り取れません。Playwright・Cypressの通常の検索はclosed内部に対応しませんが、SeleniumではWebDriverのシャドウルート取得APIから検索できる場合があります。テストのためには部品をopenで作るのが正攻法です。closedでもDevToolsのElementsパネルでは中身を確認でき、Firefoxの拡張機能ならopenOrClosedShadowRoot()で取得できます。
Shadow DOMを使うとSEOに不利ですか?
slotで差し込むライトDOMの文字は、普通のHTMLとしてページに存在します。JavaScriptのattachShadow()で作ったシャドウツリー内の文字は、レンダリングされて初めて現れます。検索で読ませたい本文はライトDOMに置き、slotで表示位置だけを決める設計が安全です。シャドウツリー側にも初回HTMLで文字を入れたい場合は、宣言型Shadow DOMでサーバーから出力します。
shadowRootの中身は外部のCSSで一切変えられないのですか?
ページ側のセレクタは境界を越えませんが、colorやfont-familyなどの継承プロパティ、CSSカスタムプロパティ(--accentなど)、作り手がpart属性で公開した要素への::part()は内部に届きます。ホストへの通常宣言同士では、部品内の:hostよりページ側が優先されます。ただし、双方が!importantの場合は部品内が優先されます。どこを上書きさせるかは、部品の作り手が変数とpartで決めます。
宣言型Shadow DOMは古いブラウザでも動きますか?
Chrome/Edge 111、Safari 16.4、Firefox 123より前の版では、<template shadowrootmode>はただのtemplateとして残り、中身は表示されません。2026年8月20日にBaseline Widely availableになったので一般向けサイトでは採用できますが、古い業務端末を対象にする場合は、HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode')で対応を判定し、未対応ならattachShadow()で作り直すフォールバックを入れます。