「壊れない」「壊さない」Web Components 〜 CSSの干渉(第2回/全3回)

梅木 和弥
mv

こんにちは。
アプリケーションエンジニアの梅木です。

前回の第1回では、WebComponentsでのデータの受け渡しについてお話をしました。

今回は 「CSSの干渉」 にフォーカスを当ててお話しようと思います。
みなさんはこんな経験はないでしょうか。

  • Tailwindが入っているPHPの管理画面に自作のボタンコンポーネントを置いたら、Bootstrapの button リセットで見た目が全部上書きされた
  • WordPressのLPに共通UIを持ち込んだら、テーマの { box-sizing: content-box; }!important の嵐でレイアウトが崩壊した
  • 自作コンポーネントで使っていた .title クラスがUIフレームワーク提供の .title と衝突して、ホスト側のレイアウトを壊してしまった

CSSの命名規則やBEM、CSS Modulesなどで運用ルールを敷いて統制を取ることも可能ですが、それは「全員がルールを守る」という前提に依存します。
技術スタックが混在する現場では、その前提が成り立たない場面の方が多いです。
※ 中でも統率が取れてる現場もたまに見かけますが、かなり稀有な組織だなと感じてます

Web Componentsの中核技術である Shadow DOM を取り入れて正しく設計出来ると、この問題を運用ルールではなくブラウザ標準の仕様レベルで解決出来ます。

ただ、Shadow DOMの隔離は強力すぎるあまりに、「必要なスタイルすら届かない」「テーマカラーが変えられない」という別の壁があったりも。

今回は、Shadow DOMについて、「カプセル化の仕組みの基礎理解」、「実務で必要になる安全な穴開け方法」の二軸でお話いたします。

1. Shadow DOMとは

ざっくり言うと「カプセル化されたDOM」です。

コンポーネントの内部に Shadow DOM を展開すると、その内部は外部のDOMツリーから完全に隔離されます。
外側のCSSは内側に届かず、内側のCSSは外側に漏れません。

文章じゃ入ってこないと思いますので、サンプル見て下さい。

See the Pen WebComponent01 by umekikazuya (@qykyopig-the-sans) on CodePen.

 

Shadow DOMである「2. Web Components」の表示を見ていただきたいんですが、ホスト側のCSSが一切影響していないですよね(色が反映されてない)。
同様に、Shadow DOM内部の .card-title というクラス名が、ホスト側の .card-title と衝突することがないことも確認できると思います(ホスト側には margin-bottom が効いてない)

 

この連載では「壊されない」「壊さない」と表現していますが、ぴったりなデモでしたね 😆

2. 隔離の代償

「Shadow DOMによる隔離」は強力なんですが、「ホスト側のリセットCSSが効かず、レイアウトが崩れる」という現象に陥ることも。

よくある失敗シナリオ

ホスト側(WordPressやBladeなど)に以下のようなグローバルCSSがあるとします。

body {
  font-family: 'Helvetica Neue', Arial, sans-serif;
  color: #333;
}
* {
  box-sizing: border-box;
}

このとき、自作コンポーネントを配置すると、テキストは綺麗なゴシック体で表示されるのに、なぜか padding や width の計算がズレてレイアウトが崩れてしまいます。
Shadow DOMの中では、モダンWeb開発の前提である box-sizing: border-box; が効かなくなっているのが原因です。

なぜこうなるのか: 継承プロパティと非継承プロパティ

Shadow DOMは「すべてのCSSを遮断する」わけではなく、CSSプロパティには継承されるもの継承されないものがあります。
Shadow DOMの境界を越えるかには仕様があり、それを理解することが必要です。

分類 Shadow DOMを透過する? 代表的なプロパティ
継承プロパティ 透過する font-family, font-size, color, line-height, letter-spacing, text-align, visibility, cursor
非継承プロパティ 遮断される box-sizing, display, margin, padding, border, width, height, background, position, overflow

判断基準はシンプルです。

テキストの見た目に関するプロパティはおおむね継承されると考えていただいて良いかと。レイアウトやボックスに関するプロパティは継承されないと理解してよいかと。
※ 厳密な一覧はMDNの各プロパティページで「継承」欄を確認できます。

実務での鉄則: 内部リセットを書く

継承されないプロパティは、コンポーネント内部で自前でリセットする必要があります。

class MyCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block; /* カスタム要素はデフォルトで inline */
        }
        *, *::before, *::after {
          box-sizing: border-box; /* 継承されないので自前で担保 */
        }
        .card {
          /* font-family, color はホスト側から自動継承されるので書かなくてよい */
          padding: 16px;
          border: 1px solid #ccc;
          border-radius: 4px;
        }
      </style>
      <div class="card">
        <slot></slot>
      </div>
    `;
  }
}

ポイントは「何を書かなくてよいか」を判断することです。
font-familycolor をShadow DOM内部で再宣言すると、ホスト側のテーマを上書きしてしまい、むしろ柔軟性を失います。

この手のやつだと、抽象クラス1個用意したくなりますよね。
全然ありだと思います。

整理するとこうなります。

やること やらないこと
box-sizing, display など非継承プロパティを内部でリセット font-family, color など継承プロパティを内部で再宣言
:host { display: block; } でカスタム要素の表示を明示 ホスト側のリセットCSSに依存する

3. 外からテーマを安全に変える

Shadow DOMで隔離する弊害はまだまだあって。
LPではボタンの色を赤、管理画面では青にしたい」という際に「テーマの切り替えができない」といった問題が出てきます。

ホスト側から !important で強引に上書きしようとしても、Shadow DOMの中には届きません。

See the Pen Untitled by umekikazuya (@qykyopig-the-sans) on CodePen.

 

これを安全に実現する手段が CSS変数::part の2つです。
どちらを使うかは「何を変えたいか」で決まります。

 

使い分けの判断基準

変えたいもの 手段
特定の値(色、サイズ、角丸など) CSS変数 ボタンの背景色をLPだけ赤にしたい
複数プロパティをまとめて(ホバー時の見た目、影、アニメーションなど) ::part ボタンのホバー時に影を付けて角丸も変えたい
レスポンシブ対応で要素の幅やレイアウトを変えたい ::part モバイルではボタンを全幅にしたい

迷ったらまず CSS変数で済むか を考えると良いと思います。
CSS変数の方が内部実装の詳細を外に露出しないため、コンポーネントの独立性が高く保たれます。

CSS変数:「値」だけを外から注入する

CSS変数はShadow DOMの境界を貫通するという特性を持っています。
そのため、以下のステップで外部から安全にスタイルを適用することが可能です。

  1. コンポーネント側は「受け付ける変数名」をAPIとして公開。(JSタブを参照)
  2. ホスト側はCSS変数を渡す。(HTMLタブを参照)

See the Pen WebComponent02 - case3-2 by umekikazuya (@qykyopig-the-sans) on CodePen.

 

第1回で解説した「フェイルセーフ」の考え方がここにも活きています。
var(--btn-bg, #333) の第2引数がフォールバック値なので、ホスト側がCSS変数を渡し忘れてもコンポーネントは壊れません。

 

::part:要素のスタイリングを「許可」する

CSS変数では対応しきれないケースがあります。たとえば「ホバー時に box-shadow を変えて、transform でちょっと浮かせたい」という場合、関連するプロパティが多すぎてCSS変数で全部公開するのは現実的ではありません。

このとき使うのが ::part() 擬似要素です。コンポーネント実装者が part 属性で「この要素は外からスタイリングしてよい」と明示的に許可する仕組みです。

See the Pen WebComponent02 - case3-3 by umekikazuya (@qykyopig-the-sans) on CodePen.
::part の重要な特性は、コンポーネント内部のDOM構造(クラス名や階層)が将来変わっても、part="base" というインターフェースさえ維持していればホスト側のCSSが壊れないことです。これは第1回で解説したSetterのAPI設計と同じ考え方です。

 

::part の設計で気をつけること

 

part をどこに・いくつ付けるかは、コンポーネントのAPI設計そのものだと思います。

付けすぎると、内部実装の詳細が外に露出しすぎて、内部のリファクタリングが困難になります。
付けなさすぎると、ホスト側が必要なカスタマイズをできず、結局「Shadow DOM外して」という要望が来ることも。

実務での目安は以下の通りです。

方針
ルート要素には基本的に part を付ける part="base"
ユーザーが見た目を変えたいと予想できる要素に付ける part="label", part="icon"
内部の構造的な要素(ラッパーやコンテナ)には付けない レイアウト用の <div> など

4. <slot>::slotted の制約と対策

テンプレートエンジンを利用する現場では、特にコンポーネントに外部からHTMLを差し込める <slot> がぴったりです。

See the Pen WebComponent02 - case4-1 by umekikazuya (@qykyopig-the-sans) on CodePen.

 

上記、HTMLタブにて、BladeやTwigでベーシックに実装が出来ますよね。
コンポーネント側では ::slotted() 擬似要素を使って、渡された要素にスタイルを当てることができます。

 

いかがですか?ここまでは簡単に組み込めそうじゃないですか?

罠: ::slotted は直下の要素しかスタイルできない

実はここに厳しい制約があって。

/* 【コンポーネント内部のスタイル】 */
::slotted(h2) {
  color: blue;       /* ✅ 効く — h2 は直下の要素 */
}

::slotted(p) {
  color: green;      /* ✅ 効く — p は直下の要素 */
}

::slotted(p span) {
  color: red;        /* ❌ 効かない — span は直下ではなく p の子要素 */
}

::slotted(p) span {
  color: red;        /* ❌ これも効かない */
}

::slotted はスロットに渡された直下の要素のみにマッチします。
その子要素には一切手が届きません。

実はこれはブラウザの仕様で。
スロットに渡された要素は「ホスト側のDOMツリー」に所属しているため、Shadow DOM側からその内部構造に干渉できないように設計されています。

対策パターン

この制約に対する実務での対処法は3つあります。

対策A:CSS変数で貫通させる

CSS変数はShadow DOMの境界もスロットの境界も貫通します。「値」を変えるだけで済む場合はこれが最もシンプルです。

コンポーネントが --highlight-color を定義し、ホスト側がそれを使う。責任分界点を明確にした上で協調する設計です。

対策B:スロットに渡す粒度を設計で制御する

::slotted が直下しか効かないなら、直下に来るように設計するというアプローチです。

コンポーネント側で名前付きスロットを複数用意し、ホスト側に「フラットに渡してもらう」ルールを設けます。テンプレートエンジン環境ではHTMLの構造をサーバー側で制御できるので、この対策が取りやすいです。

対策C:ホスト側にスタイリングを委ねる

深いDOMの見た目はホスト側のCSSで責任を持つ、と割り切る設計です。

この場合、コンポーネント側は構造とレイアウトに責任を持ち、渡されたコンテンツの装飾はホスト側に任せる、という責任分担になります。

以下、それぞれの対策の実装例です。

See the Pen WebComponent02 - case4-2 by umekikazuya (@qykyopig-the-sans) on CodePen.

 

どの対策を選ぶか

 

状況 推奨する対策
変えたいのが色やサイズなど「値」だけ A(CSS変数で貫通)
コンポーネントのAPI設計をこれから行う段階 B(スロットの粒度を設計で制御)
既存のHTML構造を変えられない・ホスト側の自由度を優先したい C(ホスト側に委ねる)

まとめ

「見た目が壊れない・壊さない」コンポーネントを作るためには、Shadow DOMの隔離仕様を理解した上で、境界線をコントロールする設計が必要です。

課題 対策
ホスト側のグローバルCSSによるレイアウト崩壊 Shadow DOMで隔離する
継承されないプロパティ(box-sizing等)が届かない コンポーネント内部で自前リセット
テーマカラーの切り替え CSS変数をAPIとして公開し、フォールバック値を設ける
複数プロパティのスタイリング変更 ::part で要素を明示的にエクスポート
::slotted が直下しか効かない CSS変数で貫通 / スロット粒度の設計 / ホスト側に委任

ここまでの第1回・第2回で、「データの受け渡し」と「見た目」の両方を堅牢に設計する方法についてお話しました。

次回は、ElementInternals を使ってWeb Componentsをブラウザ標準のフォームにネイティブ参加させる設計を解説します。

ここまで読んでいただきありがとうございます。

梅木 和弥/ アプリケーションエンジニア

Webのシステム開発における、設計や実装業務に携わっています。

夏と冬だったら冬派、Mr.ChildrenとMrs. GREEN APPLEだったらミスチル派、メッシとロナウドだとメッシ派、きのこと筍だとどっちでもいい派、たいやきは尻尾から食べる派です。

梅木 和弥 の書いた記事一覧

最新の関連記事

Download 資料ダウンロード

Drupalでの開発・運用、サーバー構築、Webサイト構築全般、制作費用などに関してお気軽にご相談ください。


Contact お問い合わせ

Drupalでの開発・運用、サーバー構築、Webサイト構築全般についてお気軽にご相談ください。専門スタッフによるDrupal無料相談も行なっております。