Web Components完全ガイド2026: カスタムエレメント・Shadow DOM・スロットの実践
Web Componentsは、フレームワークに依存しない標準的なコンポーネント開発手法です。本記事では、カスタムエレメント、Shadow DOM、テンプレート、スロットの全機能を実践的なコード例とともに徹底解説します。
Web Componentsとは
4つの主要技術
Web Componentsは以下の4つの標準技術で構成されます。
/**
* 1. Custom Elements - カスタムHTML要素の定義
* 2. Shadow DOM - カプセル化されたDOM
* 3. HTML Templates - 再利用可能なHTMLテンプレート
* 4. ES Modules - JavaScriptモジュールのインポート/エクスポート
*/
主な利点
// フレームワーク非依存
// - React、Vue、Angularなど、どこでも動作
// - JavaScriptフレームワークなしでも使用可能
// 標準技術
// - ブラウザネイティブAPI(ポリフィル不要)
// - 長期的な安定性
// カプセル化
// - スタイルとロジックの完全な分離
// - グローバルスコープの汚染を防ぐ
// 再利用性
// - 一度作成すれば、どこでも使える
// - npmパッケージとして配布可能
Custom Elements(カスタムエレメント)
基本的なカスタムエレメント
// my-button.ts
class MyButton extends HTMLElement {
constructor() {
super()
// 初期化処理
console.log('MyButton constructor called')
}
// 要素がDOMに追加されたとき
connectedCallback() {
this.innerHTML = `
<button style="
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
">
${this.textContent || 'Click me'}
</button>
`
const button = this.querySelector('button')
button?.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('my-click', {
bubbles: true,
detail: { timestamp: Date.now() }
}))
})
}
// 要素がDOMから削除されたとき
disconnectedCallback() {
console.log('MyButton removed from DOM')
}
}
// カスタムエレメントを登録
customElements.define('my-button', MyButton)
<!-- 使用例 -->
<!DOCTYPE html>
<html>
<head>
<script type="module" src="my-button.js"></script>
</head>
<body>
<my-button>Submit</my-button>
<my-button>Cancel</my-button>
<script>
document.querySelectorAll('my-button').forEach(btn => {
btn.addEventListener('my-click', (e) => {
console.log('Button clicked at', e.detail.timestamp)
})
})
</script>
</body>
</html>
属性の監視
// user-card.ts
class UserCard extends HTMLElement {
// 監視する属性を定義
static get observedAttributes() {
return ['name', 'email', 'avatar']
}
constructor() {
super()
}
// 属性が変更されたとき
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`)
this.render()
}
connectedCallback() {
this.render()
}
// ゲッター/セッター
get name() {
return this.getAttribute('name') || ''
}
set name(value: string) {
this.setAttribute('name', value)
}
get email() {
return this.getAttribute('email') || ''
}
set email(value: string) {
this.setAttribute('email', value)
}
get avatar() {
return this.getAttribute('avatar') || 'https://via.placeholder.com/100'
}
set avatar(value: string) {
this.setAttribute('avatar', value)
}
render() {
this.innerHTML = `
<div style="
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
max-width: 300px;
">
<img src="${this.avatar}" alt="${this.name}" style="
width: 100px;
height: 100px;
border-radius: 50%;
">
<h3>${this.name}</h3>
<p>${this.email}</p>
</div>
`
}
}
customElements.define('user-card', UserCard)
<!-- 使用例 -->
<user-card
name="Alice Johnson"
email="alice@example.com"
avatar="https://i.pravatar.cc/100?img=1"
></user-card>
<script>
// JavaScriptから属性を変更
const card = document.querySelector('user-card')
setTimeout(() => {
card.name = 'Alice Smith' // 表示が自動更新される
}, 2000)
</script>
ライフサイクルメソッド
// lifecycle-demo.ts
class LifecycleDemo extends HTMLElement {
constructor() {
super()
console.log('1. constructor - 要素のインスタンスが作成された')
}
connectedCallback() {
console.log('2. connectedCallback - DOMに追加された')
this.innerHTML = '<p>Lifecycle Demo Component</p>'
}
disconnectedCallback() {
console.log('3. disconnectedCallback - DOMから削除された')
}
adoptedCallback() {
console.log('4. adoptedCallback - 別のドキュメントに移動した')
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
console.log(`5. attributeChangedCallback - 属性 ${name} が変更された`)
}
static get observedAttributes() {
return ['data-value']
}
}
customElements.define('lifecycle-demo', LifecycleDemo)
Shadow DOM
基本的なShadow DOM
// shadow-button.ts
class ShadowButton extends HTMLElement {
constructor() {
super()
// Shadow DOMを作成(カプセル化)
const shadow = this.attachShadow({ mode: 'open' })
// スタイルを定義
const style = document.createElement('style')
style.textContent = `
:host {
display: inline-block;
}
button {
padding: 10px 20px;
background: var(--button-bg, #007bff);
color: var(--button-color, white);
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s;
}
button:hover {
background: var(--button-hover-bg, #0056b3);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
button:active {
transform: translateY(0);
}
`
// ボタンを作成
const button = document.createElement('button')
button.innerHTML = '<slot>Click me</slot>'
// Shadow DOMに追加
shadow.appendChild(style)
shadow.appendChild(button)
}
}
customElements.define('shadow-button', ShadowButton)
<!-- 使用例 -->
<style>
/* グローバルスタイルは影響しない */
button {
background: red !important; /* shadow-button には影響なし */
}
/* CSS変数でカスタマイズ可能 */
shadow-button {
--button-bg: #28a745;
--button-hover-bg: #218838;
}
</style>
<shadow-button>Submit</shadow-button>
<shadow-button>Cancel</shadow-button>
<!-- 通常のボタンは赤色になる -->
<button>Regular Button</button>
スロット(Slot)
// card-component.ts
class CardComponent extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
const style = document.createElement('style')
style.textContent = `
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-header {
padding: 16px;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
}
.card-body {
padding: 16px;
}
.card-footer {
padding: 16px;
background: #f8f9fa;
border-top: 1px solid #ddd;
}
::slotted(h2) {
margin: 0;
font-size: 20px;
}
::slotted(p) {
margin: 8px 0;
}
`
const template = document.createElement('template')
template.innerHTML = `
<div class="card-header">
<slot name="header">Default Header</slot>
</div>
<div class="card-body">
<slot>Default Content</slot>
</div>
<div class="card-footer">
<slot name="footer">Default Footer</slot>
</div>
`
shadow.appendChild(style)
shadow.appendChild(template.content.cloneNode(true))
}
}
customElements.define('card-component', CardComponent)
<!-- 使用例 -->
<card-component>
<h2 slot="header">Card Title</h2>
<p>This is the main content of the card.</p>
<p>It can contain multiple elements.</p>
<div slot="footer">
<button>Action 1</button>
<button>Action 2</button>
</div>
</card-component>
<!-- スロットを使わない場合はデフォルト値が表示される -->
<card-component></card-component>
名前付きスロットと動的スロット
// tabs-component.ts
class TabsComponent extends HTMLElement {
private activeTab = 0
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
const style = document.createElement('style')
style.textContent = `
.tab-buttons {
display: flex;
border-bottom: 2px solid #ddd;
}
.tab-button {
padding: 12px 24px;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tab-button.active {
color: #007bff;
border-bottom-color: #007bff;
}
.tab-content {
padding: 20px;
}
::slotted([slot^="tab-"]) {
display: none;
}
::slotted([slot^="tab-"]).active {
display: block;
}
`
shadow.innerHTML = `
<div class="tab-buttons"></div>
<div class="tab-content">
<slot></slot>
</div>
`
shadow.appendChild(style)
}
connectedCallback() {
this.render()
}
render() {
const shadow = this.shadowRoot!
const tabButtons = shadow.querySelector('.tab-buttons')!
// タブボタンを生成
const tabs = this.querySelectorAll('[slot^="tab-"]')
tabButtons.innerHTML = ''
tabs.forEach((tab, index) => {
const button = document.createElement('button')
button.className = `tab-button ${index === this.activeTab ? 'active' : ''}`
button.textContent = tab.getAttribute('data-label') || `Tab ${index + 1}`
button.addEventListener('click', () => this.switchTab(index))
tabButtons.appendChild(button)
// タブコンテンツの表示/非表示
if (index === this.activeTab) {
tab.classList.add('active')
} else {
tab.classList.remove('active')
}
})
}
switchTab(index: number) {
this.activeTab = index
this.render()
}
}
customElements.define('tabs-component', TabsComponent)
<!-- 使用例 -->
<tabs-component>
<div slot="tab-1" data-label="Profile">
<h3>Profile Information</h3>
<p>Your profile details...</p>
</div>
<div slot="tab-2" data-label="Settings">
<h3>Settings</h3>
<p>Your settings...</p>
</div>
<div slot="tab-3" data-label="Notifications">
<h3>Notifications</h3>
<p>Your notifications...</p>
</div>
</tabs-component>
テンプレート(Template)
基本的なテンプレート
// template-component.ts
class TemplateComponent extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
// テンプレートを取得
const template = document.getElementById('my-template') as HTMLTemplateElement
// テンプレートをクローンして追加
shadow.appendChild(template.content.cloneNode(true))
}
}
customElements.define('template-component', TemplateComponent)
<!-- テンプレート定義 -->
<template id="my-template">
<style>
.container {
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
</style>
<div class="container">
<h2>Template Component</h2>
<slot></slot>
</div>
</template>
<!-- 使用例 -->
<template-component>
<p>This content comes from the slot.</p>
</template-component>
インラインテンプレート
// product-card.ts
class ProductCard extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
// インラインテンプレート
const template = document.createElement('template')
template.innerHTML = `
<style>
:host {
display: block;
width: 300px;
}
.card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.image {
width: 100%;
height: 200px;
object-fit: cover;
}
.content {
padding: 16px;
}
.title {
font-size: 20px;
margin: 0 0 8px 0;
}
.price {
font-size: 24px;
color: #28a745;
font-weight: bold;
}
.button {
width: 100%;
padding: 12px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.button:hover {
background: #0056b3;
}
</style>
<div class="card">
<img class="image" src="" alt="">
<div class="content">
<h3 class="title"></h3>
<p class="description"></p>
<div class="price"></div>
<button class="button">Add to Cart</button>
</div>
</div>
`
shadow.appendChild(template.content.cloneNode(true))
}
static get observedAttributes() {
return ['title', 'description', 'price', 'image']
}
connectedCallback() {
this.render()
// ボタンクリックイベント
const button = this.shadowRoot!.querySelector('.button')
button?.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('add-to-cart', {
bubbles: true,
detail: {
title: this.getAttribute('title'),
price: this.getAttribute('price'),
}
}))
})
}
attributeChangedCallback() {
this.render()
}
render() {
const shadow = this.shadowRoot!
const image = shadow.querySelector('.image') as HTMLImageElement
const title = shadow.querySelector('.title')
const description = shadow.querySelector('.description')
const price = shadow.querySelector('.price')
if (image) image.src = this.getAttribute('image') || ''
if (image) image.alt = this.getAttribute('title') || ''
if (title) title.textContent = this.getAttribute('title') || ''
if (description) description.textContent = this.getAttribute('description') || ''
if (price) price.textContent = `$${this.getAttribute('price') || '0'}`
}
}
customElements.define('product-card', ProductCard)
<!-- 使用例 -->
<product-card
title="Wireless Headphones"
description="High-quality wireless headphones with noise cancellation"
price="199.99"
image="https://via.placeholder.com/300x200"
></product-card>
<script>
document.querySelector('product-card')?.addEventListener('add-to-cart', (e) => {
console.log('Added to cart:', e.detail)
})
</script>
実践的なコンポーネント例
モーダルダイアログ
// modal-dialog.ts
class ModalDialog extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
const template = document.createElement('template')
template.innerHTML = `
<style>
:host {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
}
:host([open]) {
display: flex;
align-items: center;
justify-content: center;
}
.backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
}
.modal {
position: relative;
background: white;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 90%;
overflow: auto;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.header {
padding: 16px;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
.close:hover {
color: #000;
}
.body {
padding: 16px;
}
.footer {
padding: 16px;
border-top: 1px solid #ddd;
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
<div class="backdrop"></div>
<div class="modal">
<div class="header">
<slot name="header">Modal Title</slot>
<button class="close">×</button>
</div>
<div class="body">
<slot></slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
</div>
`
shadow.appendChild(template.content.cloneNode(true))
}
connectedCallback() {
const shadow = this.shadowRoot!
// 閉じるボタン
shadow.querySelector('.close')?.addEventListener('click', () => {
this.close()
})
// バックドロップクリック
shadow.querySelector('.backdrop')?.addEventListener('click', () => {
this.close()
})
// ESCキー
this.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.close()
}
})
}
open() {
this.setAttribute('open', '')
this.dispatchEvent(new Event('modal-open'))
}
close() {
this.removeAttribute('open')
this.dispatchEvent(new Event('modal-close'))
}
}
customElements.define('modal-dialog', ModalDialog)
<!-- 使用例 -->
<button id="open-modal">Open Modal</button>
<modal-dialog id="my-modal">
<h2 slot="header">Confirmation</h2>
<p>Are you sure you want to continue?</p>
<div slot="footer">
<button id="cancel">Cancel</button>
<button id="confirm">Confirm</button>
</div>
</modal-dialog>
<script>
const modal = document.getElementById('my-modal')
document.getElementById('open-modal').addEventListener('click', () => {
modal.open()
})
document.getElementById('cancel').addEventListener('click', () => {
modal.close()
})
document.getElementById('confirm').addEventListener('click', () => {
console.log('Confirmed')
modal.close()
})
</script>
トースト通知
// toast-notification.ts
class ToastNotification extends HTMLElement {
private timeoutId?: number
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
const template = document.createElement('template')
template.innerHTML = `
<style>
:host {
position: fixed;
bottom: 20px;
right: 20px;
min-width: 300px;
max-width: 500px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
opacity: 0;
transform: translateX(400px);
transition: all 0.3s;
z-index: 9999;
}
:host([show]) {
opacity: 1;
transform: translateX(0);
}
:host([type="success"]) .icon {
color: #28a745;
}
:host([type="error"]) .icon {
color: #dc3545;
}
:host([type="warning"]) .icon {
color: #ffc107;
}
:host([type="info"]) .icon {
color: #17a2b8;
}
.icon {
font-size: 24px;
flex-shrink: 0;
}
.content {
flex: 1;
}
.close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #666;
flex-shrink: 0;
}
</style>
<div class="icon"></div>
<div class="content">
<slot></slot>
</div>
<button class="close">×</button>
`
shadow.appendChild(template.content.cloneNode(true))
}
static get observedAttributes() {
return ['type', 'duration']
}
connectedCallback() {
const shadow = this.shadowRoot!
// アイコン設定
const type = this.getAttribute('type') || 'info'
const icons = {
success: '✓',
error: '✕',
warning: '⚠',
info: 'ℹ',
}
const icon = shadow.querySelector('.icon')
if (icon) icon.textContent = icons[type as keyof typeof icons] || icons.info
// 閉じるボタン
shadow.querySelector('.close')?.addEventListener('click', () => {
this.hide()
})
// 自動非表示
const duration = parseInt(this.getAttribute('duration') || '3000')
if (duration > 0) {
this.timeoutId = setTimeout(() => {
this.hide()
}, duration) as unknown as number
}
}
disconnectedCallback() {
if (this.timeoutId) {
clearTimeout(this.timeoutId)
}
}
show() {
this.setAttribute('show', '')
}
hide() {
this.removeAttribute('show')
setTimeout(() => {
this.remove()
}, 300)
}
}
customElements.define('toast-notification', ToastNotification)
// ヘルパー関数
function showToast(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info', duration = 3000) {
const toast = document.createElement('toast-notification')
toast.setAttribute('type', type)
toast.setAttribute('duration', duration.toString())
toast.textContent = message
document.body.appendChild(toast)
// 少し遅延させてアニメーション発火
requestAnimationFrame(() => {
toast.show()
})
}
<!-- 使用例 -->
<button onclick="showToast('Success!', 'success')">Show Success</button>
<button onclick="showToast('Error occurred', 'error')">Show Error</button>
<button onclick="showToast('Warning message', 'warning')">Show Warning</button>
<button onclick="showToast('Information', 'info')">Show Info</button>
アコーディオン
// accordion-item.ts
class AccordionItem extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
const template = document.createElement('template')
template.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 8px;
}
.header {
padding: 16px;
background: #f8f9fa;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
}
.header:hover {
background: #e9ecef;
}
.icon {
transition: transform 0.3s;
}
:host([open]) .icon {
transform: rotate(180deg);
}
.content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
}
:host([open]) .content {
max-height: 1000px;
}
.content-inner {
padding: 16px;
}
</style>
<div class="header">
<slot name="header">Accordion Item</slot>
<span class="icon">▼</span>
</div>
<div class="content">
<div class="content-inner">
<slot></slot>
</div>
</div>
`
shadow.appendChild(template.content.cloneNode(true))
}
connectedCallback() {
const shadow = this.shadowRoot!
shadow.querySelector('.header')?.addEventListener('click', () => {
this.toggle()
})
}
toggle() {
if (this.hasAttribute('open')) {
this.removeAttribute('open')
this.dispatchEvent(new Event('accordion-close'))
} else {
this.setAttribute('open', '')
this.dispatchEvent(new Event('accordion-open'))
}
}
}
customElements.define('accordion-item', AccordionItem)
<!-- 使用例 -->
<accordion-item>
<span slot="header">Section 1</span>
<p>Content for section 1...</p>
</accordion-item>
<accordion-item open>
<span slot="header">Section 2</span>
<p>Content for section 2...</p>
</accordion-item>
<accordion-item>
<span slot="header">Section 3</span>
<p>Content for section 3...</p>
</accordion-item>
TypeScript型定義
// types.ts
// カスタムエレメントの型定義
declare global {
interface HTMLElementTagNameMap {
'my-button': MyButton
'user-card': UserCard
'shadow-button': ShadowButton
'card-component': CardComponent
'tabs-component': TabsComponent
'product-card': ProductCard
'modal-dialog': ModalDialog
'toast-notification': ToastNotification
'accordion-item': AccordionItem
}
}
// 属性の型定義
interface UserCardAttributes {
name: string
email: string
avatar?: string
}
// イベントの型定義
interface MyButtonClickEvent extends CustomEvent {
detail: {
timestamp: number
}
}
// React用の型定義(JSX)
declare namespace JSX {
interface IntrinsicElements {
'my-button': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>
'user-card': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & UserCardAttributes,
HTMLElement
>
}
}
フレームワーク統合
React
// React コンポーネントとして使用
import React, { useRef, useEffect } from 'react'
import './my-button' // Web Component をインポート
interface MyButtonProps {
onClick?: (e: CustomEvent) => void
children?: React.ReactNode
}
export function MyButtonWrapper({ onClick, children }: MyButtonProps) {
const ref = useRef<HTMLElement>(null)
useEffect(() => {
const element = ref.current
if (element && onClick) {
element.addEventListener('my-click', onClick as EventListener)
return () => {
element.removeEventListener('my-click', onClick as EventListener)
}
}
}, [onClick])
return <my-button ref={ref}>{children}</my-button>
}
// 使用例
function App() {
return (
<div>
<MyButtonWrapper onClick={(e) => console.log(e.detail)}>
Click Me
</MyButtonWrapper>
</div>
)
}
Vue
<!-- Vue コンポーネントとして使用 -->
<template>
<my-button @my-click="handleClick">
{{ label }}
</my-button>
</template>
<script setup lang="ts">
import './my-button' // Web Component をインポート
const props = defineProps<{
label: string
}>()
const handleClick = (e: CustomEvent) => {
console.log('Clicked:', e.detail)
}
</script>
Angular
// app.module.ts
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'
@NgModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA], // Web Components を許可
})
export class AppModule {}
<!-- app.component.html -->
<my-button (my-click)="handleClick($event)">
Click Me
</my-button>
パフォーマンス最適化
遅延読み込み
// lazy-load-component.ts
class LazyImage extends HTMLElement {
private observer?: IntersectionObserver
connectedCallback() {
// Intersection Observer でビューポート内に入ったら読み込み
this.observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage()
this.observer?.disconnect()
}
})
})
this.observer.observe(this)
}
disconnectedCallback() {
this.observer?.disconnect()
}
loadImage() {
const src = this.getAttribute('data-src')
if (src) {
const img = document.createElement('img')
img.src = src
img.alt = this.getAttribute('alt') || ''
this.appendChild(img)
}
}
}
customElements.define('lazy-image', LazyImage)
仮想スクロール
// virtual-list.ts
class VirtualList extends HTMLElement {
private items: any[] = []
private itemHeight = 50
private visibleCount = 10
private scrollTop = 0
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
const template = document.createElement('template')
template.innerHTML = `
<style>
:host {
display: block;
height: 500px;
overflow-y: auto;
position: relative;
}
.viewport {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.item {
height: ${this.itemHeight}px;
border-bottom: 1px solid #ddd;
padding: 12px;
}
</style>
<div class="viewport"></div>
`
shadow.appendChild(template.content.cloneNode(true))
}
connectedCallback() {
this.addEventListener('scroll', () => {
this.scrollTop = this.scrollTop
this.render()
})
}
setItems(items: any[]) {
this.items = items
this.render()
}
render() {
const shadow = this.shadowRoot!
const viewport = shadow.querySelector('.viewport') as HTMLElement
const startIndex = Math.floor(this.scrollTop / this.itemHeight)
const endIndex = Math.min(startIndex + this.visibleCount, this.items.length)
viewport.style.height = `${this.items.length * this.itemHeight}px`
viewport.style.paddingTop = `${startIndex * this.itemHeight}px`
viewport.innerHTML = this.items
.slice(startIndex, endIndex)
.map(item => `<div class="item">${item}</div>`)
.join('')
}
}
customElements.define('virtual-list', VirtualList)
まとめ
Web Componentsは、フレームワークに依存しない標準的なコンポーネント開発手法です。
主な利点
- フレームワーク非依存(React、Vue、Angularで使用可能)
- 完全なカプセル化(Shadow DOM)
- ブラウザ標準API(ポリフィル不要)
- 長期的な安定性
適用場面
- デザインシステム・UIライブラリ
- マイクロフロントエンド
- レガシーシステムへの段階的導入
- フレームワーク間での再利用
注意点
- SEOに注意(Shadow DOMは検索エンジンに見えにくい)
- フレームワークの状態管理との統合が必要
- IE11非対応(モダンブラウザのみ)
2026年現在、主要ブラウザでWeb Componentsは完全サポートされており、本番環境での採用事例も増加しています。
参考リンク
- MDN Web Components
- web.dev Custom Elements
- lit.dev - Web Components開発ライブラリ