T-CREATOR

Web Components スタイリング速見表:`::part`/`::slotted`/AdoptedStyleSheets(Constructable Stylesheets)

Web Components スタイリング速見表:`::part`/`::slotted`/AdoptedStyleSheets(Constructable Stylesheets)

モダンな Web 開発において、Web Components は再利用可能で保守性の高いコンポーネント作成を可能にします。しかし、Shadow DOM によるスタイルカプセル化は従来の CSS 手法では対応できない課題を生みます。

本記事では、Web Components のスタイリングを効果的に行うための 3 つの主要技術、::partセレクタ、::slottedセレクタ、そして Adopted StyleSheets(Constructable Stylesheets)について詳しく解説いたします。これらの技術をマスターすることで、柔軟で効率的な Web Components スタイリングが実現できるでしょう。

背景

Web Components とスタイルカプセル化

Web Components は、HTML、CSS、JavaScript を組み合わせた再利用可能なカスタム要素を作成するための Web 標準技術です。Shadow DOM という仕組みにより、コンポーネント内部のスタイルと DOM が外部から分離され、予期しないスタイルの競合を防げます。

以下の図は、Web Components と Shadow DOM の基本構造を示しています。

mermaidflowchart TB
  light["Light DOM<br/>(通常のDOM)"]
  shadow["Shadow DOM<br/>(カプセル化)"]
  host["Shadow Host<br/>(Web Component)"]

  light -->|"挿入"| host
  host -->|"内包"| shadow
  shadow -->|"隔離された<br/>スタイル領域"| styles["Component Styles"]

  subgraph isolation["スタイル分離"]
    styles
    internal["内部要素"]
  end

この構造により、コンポーネント内部のスタイルは外部に影響せず、外部のスタイルもコンポーネント内部に影響しません。

スタイルカプセル化のメリット

#メリット説明
1予測可能性スタイルの適用範囲が明確で、予期しない影響を回避
2再利用性他のプロジェクトでも同じスタイリングで動作
3保守性コンポーネント単位でのスタイル管理が可能
4独立性外部ライブラリのスタイルとの競合を防止

課題

従来の CSS では解決できない Shadow DOM のスタイリング問題

Shadow DOM のスタイルカプセル化は多くのメリットをもたらしますが、同時に新たなスタイリング課題も生みます。

以下の図は、従来の CSS と Shadow DOM でのスタイル適用の違いを示しています。

mermaidflowchart LR
  subgraph traditional["従来のCSS"]
    global_css["グローバルCSS"] -->|"直接適用"| all_elements["全ての要素"]
  end

  subgraph shadow_dom["Shadow DOM"]
    global_css2["グローバルCSS"] -.->|"適用されない"| shadow_root["Shadow Root"]
    shadow_root -->|"隔離"| shadow_elements["Shadow内要素"]
  end

  style shadow_root fill:#ffcccc
  style shadow_elements fill:#ffcccc

主要な課題と制約

#課題具体的な問題
1外部からの部分制御不可コンポーネント内の特定要素のみスタイル変更したい
2スロットコンテンツの制御困難挿入されたコンテンツのスタイリングが複雑
3スタイルの重複同じスタイルを複数コンポーネントで再定義
4動的スタイル変更の制約ランタイムでのスタイル変更が困難
5テーマ適用の複雑さ全体的なデザインテーマの統一が困難

これらの課題を解決するために、最新の CSS 仕様では新しいセレクタと API が提供されています。

解決策

::partセレクタによる部分的スタイリング

::partセレクタは、Web Component 内部の特定要素を外部からスタイリングするための仕組みです。コンポーネント開発者が意図的に公開した部分のみを、コンポーネント利用者がカスタマイズできます。

基本的な仕組み

コンポーネント内部でpart属性を指定した要素に対して、外部から::part()セレクタでスタイリングできます。

javascript// Web Component内部での定義
class CustomButton extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .btn { 
          padding: 8px 16px; 
          border: 1px solid #ccc; 
        }
      </style>
      <button part="button" class="btn">
        <slot></slot>
      </button>
    `;
  }
}
css/* 外部からのスタイリング */
custom-button::part(button) {
  background-color: #007bff;
  color: white;
  border-radius: 4px;
}

/* 擬似クラスとの組み合わせ */
custom-button::part(button):hover {
  background-color: #0056b3;
}

::slottedセレクタによるスロットコンテンツのスタイリング

::slottedセレクタは、Shadow DOM 内部から Light DOM(スロットに挿入されるコンテンツ)をスタイリングするための仕組みです。

スロットの概念理解

スロットは、Web Component 内部に外部コンテンツを挿入するためのプレースホルダーです。::slottedにより、挿入されたコンテンツに対してコンポーネント側からスタイルを適用できます。

javascript// Web Component内でのスロット定義
class CardComponent extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .card {
          border: 1px solid #ddd;
          padding: 16px;
          border-radius: 8px;
        }
        
        /* スロットコンテンツのスタイリング */
        ::slotted(h2) {
          margin-top: 0;
          color: #333;
          font-size: 1.5em;
        }
        
        ::slotted(.highlight) {
          background-color: #fff3cd;
          padding: 4px 8px;
        }
      </style>
      
      <div class="card">
        <slot name="title"></slot>
        <slot></slot>
      </div>
    `;
  }
}
html<!-- コンポーネントの使用例 -->
<card-component>
  <h2 slot="title">カードタイトル</h2>
  <p class="highlight">ハイライトされたテキスト</p>
  <p>通常のテキストコンテンツ</p>
</card-component>

Adopted StyleSheets(Constructable Stylesheets)による効率的なスタイル管理

Adopted StyleSheets は、CSSStyleSheet オブジェクトを動的に作成し、複数の Shadow DOM で共有できる仕組みです。スタイルの重複を避け、メモリ効率とパフォーマンスを向上させます。

Constructable Stylesheets の基本実装

javascript// 共有スタイルシートの作成
const sharedStyles = new CSSStyleSheet();
sharedStyles.replaceSync(`
  :host {
    display: block;
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  }
  
  .button {
    padding: 12px 24px;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    transition: all 0.2s ease;
  }
  
  .primary {
    background-color: #007bff;
    color: white;
  }
  
  .secondary {
    background-color: #6c757d;
    color: white;
  }
`);
javascript// 複数のコンポーネントで共有
class PrimaryButton extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });

    // 共有スタイルシートの適用
    this.shadowRoot.adoptedStyleSheets = [sharedStyles];

    this.shadowRoot.innerHTML = `
      <button class="button primary" part="button">
        <slot></slot>
      </button>
    `;
  }
}

class SecondaryButton extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });

    // 同じスタイルシートを再利用
    this.shadowRoot.adoptedStyleSheets = [sharedStyles];

    this.shadowRoot.innerHTML = `
      <button class="button secondary" part="button">
        <slot></slot>
      </button>
    `;
  }
}

具体例

::partの実装例とベストプラクティス

実際のプロジェクトでの::partセレクタの活用例として、カスタマイズ可能なフォームコンポーネントを作成してみましょう。

フォームコンポーネントの実装

javascript// カスタマイズ可能な入力フィールドコンポーネント
class CustomInput extends HTMLElement {
  static get observedAttributes() {
    return ['label', 'type', 'placeholder', 'required'];
  }

  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.render();
  }

  attributeChangedCallback() {
    if (this.shadowRoot) {
      this.render();
    }
  }

  render() {
    const label = this.getAttribute('label') || '';
    const type = this.getAttribute('type') || 'text';
    const placeholder =
      this.getAttribute('placeholder') || '';
    const required = this.hasAttribute('required');

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          margin-bottom: 16px;
        }
        
        .field-container {
          display: flex;
          flex-direction: column;
        }
        
        .label {
          font-weight: 500;
          margin-bottom: 4px;
          color: #374151;
        }
        
        .input {
          padding: 8px 12px;
          border: 1px solid #d1d5db;
          border-radius: 4px;
          font-size: 14px;
          transition: border-color 0.2s ease;
        }
        
        .input:focus {
          outline: none;
          border-color: #3b82f6;
          box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
        }
        
        .required::after {
          content: ' *';
          color: #ef4444;
        }
      </style>
      
      <div class="field-container">
        ${
          label
            ? `<label part="label" class="label ${
                required ? 'required' : ''
              }">${label}</label>`
            : ''
        }
        <input 
          part="input" 
          class="input" 
          type="${type}" 
          placeholder="${placeholder}"
          ${required ? 'required' : ''}
        />
      </div>
    `;
  }
}

customElements.define('custom-input', CustomInput);

外部からのカスタマイズ例

css/* プロジェクト固有のスタイリング */
custom-input::part(label) {
  font-family: 'Inter', sans-serif;
  font-size: 16px;
  color: #1f2937;
}

custom-input::part(input) {
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  padding: 12px 16px;
  font-size: 16px;
}

custom-input::part(input):focus {
  border-color: #10b981;
  box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
}

/* エラー状態のスタイリング */
custom-input[error]::part(input) {
  border-color: #ef4444;
}

custom-input[error]::part(label) {
  color: #ef4444;
}

ベストプラクティス

#原則説明
1適切な粒度細かすぎず、粗すぎない適切なレベルで part を公開
2命名規則一貫性のある part 名を使用(例:button, input, label)
3ドキュメント化利用可能な part とその用途を明確に文書化
4後方互換性part 名の変更は破壊的変更として慎重に検討

::slottedの活用パターンと注意点

::slottedセレクタの実用的な活用例として、コンテンツカードコンポーネントを作成します。

高度なスロット活用例

javascript// 多機能コンテンツカードコンポーネント
class ContentCard extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          background: white;
          border-radius: 12px;
          box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
          overflow: hidden;
        }
        
        .card-header {
          padding: 20px 24px 0;
        }
        
        .card-body {
          padding: 16px 24px;
        }
        
        .card-footer {
          padding: 0 24px 20px;
          border-top: 1px solid #e5e7eb;
          margin-top: 16px;
          padding-top: 16px;
        }
        
        /* スロットコンテンツのスタイリング */
        ::slotted([slot="title"]) {
          margin: 0 0 8px 0;
          font-size: 1.5em;
          font-weight: 600;
          color: #1f2937;
          line-height: 1.2;
        }
        
        ::slotted([slot="subtitle"]) {
          margin: 0 0 16px 0;
          font-size: 0.9em;
          color: #6b7280;
          font-weight: 400;
        }
        
        ::slotted(p) {
          margin: 0 0 12px 0;
          line-height: 1.6;
          color: #374151;
        }
        
        ::slotted(p:last-child) {
          margin-bottom: 0;
        }
        
        ::slotted([slot="action"]) {
          display: inline-flex;
          gap: 8px;
        }
        
        ::slotted(button) {
          padding: 8px 16px;
          border: 1px solid #d1d5db;
          border-radius: 6px;
          background: white;
          cursor: pointer;
          font-size: 14px;
          transition: all 0.2s ease;
        }
        
        ::slotted(button:hover) {
          background: #f9fafb;
          border-color: #9ca3af;
        }
        
        ::slotted(button.primary) {
          background: #3b82f6;
          color: white;
          border-color: #3b82f6;
        }
        
        ::slotted(button.primary:hover) {
          background: #2563eb;
          border-color: #2563eb;
        }
      </style>
      
      <div class="card-header">
        <slot name="title"></slot>
        <slot name="subtitle"></slot>
      </div>
      
      <div class="card-body">
        <slot></slot>
      </div>
      
      <div class="card-footer">
        <slot name="action"></slot>
      </div>
    `;
  }
}

customElements.define('content-card', ContentCard);

使用例とコンテンツの挿入

html<!-- 豊富なコンテンツを含むカード -->
<content-card>
  <h2 slot="title">プロジェクト進捗レポート</h2>
  <div slot="subtitle">
    2024年第1四半期 - 最終更新: 3月28日
  </div>

  <p>
    本四半期のプロジェクト進捗は順調で、予定していた主要機能の90%が完成いたしました。
  </p>
  <p>
    残りのタスクについても来月中の完了を予定しており、全体的なスケジュールに大きな遅れはございません。
  </p>

  <div slot="action">
    <button class="primary">詳細を見る</button>
    <button>ダウンロード</button>
  </div>
</content-card>

::slotted使用時の注意点

#注意点対策
1詳細度の制限直接の子要素のみ選択可能、深い階層は不可
2複雑セレクタ不可子孫セレクタや兄弟セレクタは使用できない
3スタイル優先順位Light DOM 側のスタイルが優先される場合がある
4動的コンテンツJavaScript で動的に追加された要素には適用されない場合がある

Adopted StyleSheets の導入手順と実用例

大規模な Web Components ライブラリでの効率的なスタイル管理を実現するため、Adopted StyleSheets の実装例をご紹介します。

テーマシステムの構築

javascript// テーマ管理システムの実装
class ThemeManager {
  constructor() {
    this.themes = new Map();
    this.currentTheme = 'default';
    this.baseStyles = this.createBaseStyles();
  }

  // 基本スタイルシートの作成
  createBaseStyles() {
    const baseSheet = new CSSStyleSheet();
    baseSheet.replaceSync(`
      :host {
        box-sizing: border-box;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      }
      
      *, *::before, *::after {
        box-sizing: inherit;
      }
      
      .sr-only {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
      }
    `);
    return baseSheet;
  }

  // テーマスタイルシートの作成と登録
  registerTheme(name, cssText) {
    const themeSheet = new CSSStyleSheet();
    themeSheet.replaceSync(cssText);
    this.themes.set(name, themeSheet);
  }

  // コンポーネントにスタイルシートを適用
  applyToComponent(shadowRoot, additionalSheets = []) {
    const currentThemeSheet = this.themes.get(
      this.currentTheme
    );
    const sheets = [this.baseStyles];

    if (currentThemeSheet) {
      sheets.push(currentThemeSheet);
    }

    sheets.push(...additionalSheets);
    shadowRoot.adoptedStyleSheets = sheets;
  }

  // テーマの切り替え
  switchTheme(themeName) {
    this.currentTheme = themeName;
    // 既存のコンポーネントのスタイルを更新
    this.updateAllComponents();
  }

  updateAllComponents() {
    // 実装は使用フレームワークによって異なる
    // ここでは概念的な実装を示す
    document.querySelectorAll('*').forEach((element) => {
      if (element.shadowRoot && element.updateStyles) {
        element.updateStyles();
      }
    });
  }
}

// グローバルテーママネージャーのインスタンス
const themeManager = new ThemeManager();

テーマ定義の実装

javascript// デフォルトテーマの定義
themeManager.registerTheme(
  'default',
  `
  :host {
    --primary-color: #3b82f6;
    --primary-hover: #2563eb;
    --secondary-color: #6b7280;
    --success-color: #10b981;
    --error-color: #ef4444;
    --warning-color: #f59e0b;
    
    --background-primary: #ffffff;
    --background-secondary: #f9fafb;
    --text-primary: #1f2937;
    --text-secondary: #6b7280;
    
    --border-color: #d1d5db;
    --border-radius: 6px;
    --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
    --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
  }
`
);

// ダークテーマの定義
themeManager.registerTheme(
  'dark',
  `
  :host {
    --primary-color: #60a5fa;
    --primary-hover: #3b82f6;
    --secondary-color: #9ca3af;
    --success-color: #34d399;
    --error-color: #f87171;
    --warning-color: #fbbf24;
    
    --background-primary: #1f2937;
    --background-secondary: #111827;
    --text-primary: #f9fafb;
    --text-secondary: #d1d5db;
    
    --border-color: #374151;
    --border-radius: 6px;
    --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
    --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
  }
`
);

コンポーネントでの Adopted StyleSheets 活用

javascript// テーマ対応ボタンコンポーネント
class ThemedButton extends HTMLElement {
  static get observedAttributes() {
    return ['variant', 'size', 'disabled'];
  }

  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.setupStyles();
    this.render();
  }

  setupStyles() {
    // コンポーネント固有のスタイルシート
    this.componentStyles = new CSSStyleSheet();
    this.componentStyles.replaceSync(`
      :host {
        display: inline-block;
      }
      
      .button {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        font-weight: 500;
        text-decoration: none;
        cursor: pointer;
        transition: all 0.2s ease-in-out;
        border: none;
        outline: none;
        
        background-color: var(--primary-color);
        color: var(--background-primary);
        border-radius: var(--border-radius);
        box-shadow: var(--shadow-sm);
      }
      
      .button:hover:not(:disabled) {
        background-color: var(--primary-hover);
        box-shadow: var(--shadow-md);
      }
      
      .button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
      
      /* サイズバリエーション */
      .size-sm {
        padding: 6px 12px;
        font-size: 14px;
      }
      
      .size-md {
        padding: 8px 16px;
        font-size: 16px;
      }
      
      .size-lg {
        padding: 12px 24px;
        font-size: 18px;
      }
      
      /* バリアントスタイル */
      .variant-secondary {
        background-color: var(--secondary-color);
        color: var(--background-primary);
      }
      
      .variant-outline {
        background-color: transparent;
        color: var(--primary-color);
        border: 1px solid var(--primary-color);
      }
      
      .variant-ghost {
        background-color: transparent;
        color: var(--primary-color);
        box-shadow: none;
      }
    `);

    // テーママネージャーからスタイルを適用
    themeManager.applyToComponent(this.shadowRoot, [
      this.componentStyles,
    ]);
  }

  render() {
    const variant =
      this.getAttribute('variant') || 'primary';
    const size = this.getAttribute('size') || 'md';
    const disabled = this.hasAttribute('disabled');

    this.shadowRoot.innerHTML = `
      <button 
        part="button"
        class="button size-${size} variant-${variant}"
        ${disabled ? 'disabled' : ''}
      >
        <slot></slot>
      </button>
    `;
  }

  // テーマ変更時のスタイル更新
  updateStyles() {
    this.setupStyles();
  }

  attributeChangedCallback() {
    if (this.shadowRoot) {
      this.render();
    }
  }
}

customElements.define('themed-button', ThemedButton);

パフォーマンス最適化の実装

javascript// スタイルシートキャッシュシステム
class StyleSheetCache {
  constructor() {
    this.cache = new Map();
    this.loadingPromises = new Map();
  }

  // 外部CSSファイルの動的読み込み
  async loadStyleSheet(url) {
    // キャッシュから取得
    if (this.cache.has(url)) {
      return this.cache.get(url);
    }

    // 既に読み込み中の場合は同じPromiseを返す
    if (this.loadingPromises.has(url)) {
      return this.loadingPromises.get(url);
    }

    // 新規読み込み
    const loadPromise = this.fetchAndCreateStyleSheet(url);
    this.loadingPromises.set(url, loadPromise);

    try {
      const styleSheet = await loadPromise;
      this.cache.set(url, styleSheet);
      this.loadingPromises.delete(url);
      return styleSheet;
    } catch (error) {
      this.loadingPromises.delete(url);
      throw error;
    }
  }

  async fetchAndCreateStyleSheet(url) {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Failed to load stylesheet: ${url}`);
    }

    const cssText = await response.text();
    const styleSheet = new CSSStyleSheet();
    await styleSheet.replace(cssText);

    return styleSheet;
  }
}

const styleCache = new StyleSheetCache();

まとめ

各手法の使い分けと組み合わせ方針

Web Components の効果的なスタイリングには、3 つの主要技術を適切に組み合わせることが重要です。

技術選択の指針

以下の表は、各技術の特徴と適用場面をまとめたものです。

技術適用場面メリット制約
::part外部からの部分カスタマイズ柔軟性、カプセル化維持事前定義が必要
::slotted挿入コンテンツの制御動的コンテンツ対応直接の子要素のみ
Adopted StyleSheets大規模なスタイル管理パフォーマンス、メモリ効率モダンブラウザのみ

組み合わせパターンの推奨事項

mermaidflowchart TD
  start["スタイリング要件"] --> external{"外部カスタマイズ<br/>が必要?"}
  external -->|Yes| part_design["::part セレクタ<br/>での設計"]
  external -->|No| internal["内部スタイリング<br/>のみ"]

  part_design --> slot_content{"スロットコンテンツ<br/>がある?"}
  internal --> slot_content

  slot_content -->|Yes| slotted_style["::slotted セレクタ<br/>の適用"]
  slot_content -->|No| shared_check{"複数コンポーネントで<br/>スタイル共有?"}

  slotted_style --> shared_check

  shared_check -->|Yes| adopted_sheets["Adopted StyleSheets<br/>での最適化"]
  shared_check -->|No| individual["個別スタイル<br/>シート"]

  adopted_sheets --> complete["統合的な<br/>スタイリング完成"]
  individual --> complete

実践的な実装戦略

#戦略実装のポイント
1段階的導入まず基本的な::partから始め、必要に応じて他の技術を追加
2一貫性の確保命名規則とスタイルガイドラインを策定
3パフォーマンス重視大規模プロジェクトでは Adopted StyleSheets を積極活用
4ドキュメント化利用可能な part と slot を明確に文書化
5テスト戦略スタイリングの動作確認を自動化

これらの技術をマスターすることで、保守性が高く、柔軟で効率的な Web Components スタイリングが実現できます。モダンな Web 開発において、これらの知識は必須のスキルとなるでしょう。

関連リンク