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 開発において、これらの知識は必須のスキルとなるでしょう。
関連リンク
- article
Web Components スタイリング速見表:`::part`/`::slotted`/AdoptedStyleSheets(Constructable Stylesheets)
- article
Web Components を Vite + TypeScript + yarn で最短セットアップする完全手順
- article
Web Components 全体像を一枚図で理解する:Shadow DOM/slots/ElementInternals の関係
- article
Web Components Shadow DOM を使いこなす - スタイルカプセル化と Slot 活用テクニック
- article
Web Components Custom Elements の作り方完全ガイド - ライフサイクルから高度な機能まで
- article
ゼロから始める Web Components - HTML だけで作る再利用可能な UI 部品
- article
Lodash クイックレシピ :配列・オブジェクト変換の“定番ひな形”集
- article
Zod クイックリファレンス:`string/number/boolean/date/enum/literal` 速見表
- article
Web Components スタイリング速見表:`::part`/`::slotted`/AdoptedStyleSheets(Constructable Stylesheets)
- article
LangChain LCEL 実用テンプレ 30:map/branch/batch/select の書き方速見表
- article
Vue.js の状態管理比較:Pinia vs Vuex 4 vs 外部(Nanostores 等)実運用レビュー
- article
Jotai クイックリファレンス:atom/read/write/derived の書き方早見表
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来