CSS実践入門第8回:ウィジェットシステム構築 - 再利用可能なコンポーネント設計

モジュラーなウィジェットシステムの設計・実装方法を詳しく解説。CSS設計パターン、コンポーネントの分離、保守性の高いコード構造の構築テクニックを紹介します。

現代のWeb開発において、コンポーネントベースの設計は保守性とスケーラビリティの向上に欠かせません。今回は、再利用可能なウィジェットシステムの構築方法を詳しく解説し、効率的なCSS設計パターンとモジュラー開発のテクニックを紹介します。

この記事で学べること

  • モジュラーなウィジェットシステムの設計原則
  • BEM(Block Element Modifier)方法論の実践的活用
  • CSS Variables を活用した柔軟なテーマシステム
  • コンポーネントの分離と依存関係の管理
  • スケーラブルなCSS アーキテクチャの構築

1. ウィジェットシステムの設計原則

基本的な設計コンセプト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/* ウィジェットの基本構造 */
.widget {
    /* コンテナの基本スタイル */
    --widget-padding: 24px;
    --widget-border-radius: 12px;
    --widget-background: var(--color-surface);
    --widget-border: 1px solid var(--color-border);
    --widget-shadow: 0 2px 8px var(--color-shadow);
    
    padding: var(--widget-padding);
    background: var(--widget-background);
    border: var(--widget-border);
    border-radius: var(--widget-border-radius);
    box-shadow: var(--widget-shadow);
    margin-bottom: 24px;
}

/* ウィジェットヘッダー */
.widget__header {
    --header-font-size: 18px;
    --header-font-weight: 600;
    --header-color: var(--color-text-primary);
    --header-margin-bottom: 16px;
    
    font-size: var(--header-font-size);
    font-weight: var(--header-font-weight);
    color: var(--header-color);
    margin-bottom: var(--header-margin-bottom);
    display: flex;
    align-items: center;
    justify-content: space-between;
}

/* ウィジェットボディ */
.widget__body {
    --body-font-size: 14px;
    --body-line-height: 1.6;
    --body-color: var(--color-text-secondary);
    
    font-size: var(--body-font-size);
    line-height: var(--body-line-height);
    color: var(--body-color);
}

/* ウィジェットフッター */
.widget__footer {
    --footer-margin-top: 16px;
    --footer-padding-top: 16px;
    --footer-border-top: 1px solid var(--color-border-light);
    
    margin-top: var(--footer-margin-top);
    padding-top: var(--footer-padding-top);
    border-top: var(--footer-border-top);
}

テーマシステムの構築

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/* カラーシステムの定義 */
:root {
    /* プライマリカラー */
    --color-primary: #3498db;
    --color-primary-dark: #2980b9;
    --color-primary-light: #5dade2;
    
    /* セカンダリカラー */
    --color-secondary: #95a5a6;
    --color-secondary-dark: #7f8c8d;
    --color-secondary-light: #bdc3c7;
    
    /* テキストカラー */
    --color-text-primary: #2c3e50;
    --color-text-secondary: #7f8c8d;
    --color-text-muted: #bdc3c7;
    
    /* 背景カラー */
    --color-surface: #ffffff;
    --color-background: #f8f9fa;
    --color-background-dark: #ecf0f1;
    
    /* ボーダー */
    --color-border: #dee2e6;
    --color-border-light: #f1f3f4;
    --color-border-dark: #ced4da;
    
    /* シャドウ */
    --color-shadow: rgba(0, 0, 0, 0.1);
    --color-shadow-light: rgba(0, 0, 0, 0.05);
    --color-shadow-dark: rgba(0, 0, 0, 0.15);
    
    /* ステータスカラー */
    --color-success: #27ae60;
    --color-warning: #f39c12;
    --color-danger: #e74c3c;
    --color-info: #3498db;
}

/* ダークテーマ */
[data-theme="dark"] {
    --color-text-primary: #ecf0f1;
    --color-text-secondary: #bdc3c7;
    --color-text-muted: #95a5a6;
    
    --color-surface: #34495e;
    --color-background: #2c3e50;
    --color-background-dark: #1a252f;
    
    --color-border: #4a5e6a;
    --color-border-light: #3e5360;
    --color-border-dark: #566b77;
    
    --color-shadow: rgba(0, 0, 0, 0.3);
}

/* カンパクトテーマ */
[data-density="compact"] {
    --widget-padding: 16px;
    --header-font-size: 16px;
    --header-margin-bottom: 12px;
    --body-font-size: 13px;
    --footer-margin-top: 12px;
    --footer-padding-top: 12px;
}

/* スペーシングシステム */
:root {
    --spacing-xs: 4px;
    --spacing-sm: 8px;
    --spacing-md: 16px;
    --spacing-lg: 24px;
    --spacing-xl: 32px;
    --spacing-2xl: 48px;
}

2. 実用的なウィジェット実装

ニュースウィジェット

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/* ニュースウィジェット */
.widget--news {
    --news-item-padding: var(--spacing-sm);
    --news-item-border-bottom: 1px solid var(--color-border-light);
    --news-title-color: var(--color-text-primary);
    --news-meta-color: var(--color-text-muted);
    --news-hover-background: var(--color-background-dark);
}

.news-item {
    padding: var(--news-item-padding) 0;
    border-bottom: var(--news-item-border-bottom);
    transition: background-color 0.2s ease;
}

.news-item:hover {
    background-color: var(--news-hover-background);
    margin: 0 calc(-1 * var(--widget-padding));
    padding-left: var(--widget-padding);
    padding-right: var(--widget-padding);
    border-radius: 6px;
}

.news-item:last-child {
    border-bottom: none;
}

.news-item__title {
    font-size: 14px;
    font-weight: 500;
    color: var(--news-title-color);
    margin-bottom: 4px;
    line-height: 1.4;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.news-item__meta {
    font-size: 12px;
    color: var(--news-meta-color);
    display: flex;
    align-items: center;
    gap: var(--spacing-sm);
}

.news-item__tag {
    background: var(--color-primary-light);
    color: var(--color-primary-dark);
    padding: 2px 6px;
    border-radius: 4px;
    font-size: 11px;
    font-weight: 500;
}

.news-item__time {
    font-size: 11px;
    color: var(--color-text-muted);
}

ランキングウィジェット

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/* ランキングウィジェット */
.widget--ranking {
    --ranking-item-padding: var(--spacing-sm);
    --ranking-number-size: 20px;
    --ranking-number-font-weight: 600;
}

.ranking-item {
    display: flex;
    align-items: center;
    padding: var(--ranking-item-padding) 0;
    gap: var(--spacing-md);
    transition: transform 0.2s ease;
}

.ranking-item:hover {
    transform: translateX(4px);
}

.ranking-item__number {
    width: var(--ranking-number-size);
    height: var(--ranking-number-size);
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: var(--ranking-number-font-weight);
    font-size: 12px;
    border-radius: 50%;
    flex-shrink: 0;
}

/* 1位~3位の特別なスタイリング */
.ranking-item:nth-child(1) .ranking-item__number {
    background: linear-gradient(45deg, #ffd700, #ffed4e);
    color: #b8860b;
    box-shadow: 0 2px 4px rgba(255, 215, 0, 0.3);
}

.ranking-item:nth-child(2) .ranking-item__number {
    background: linear-gradient(45deg, #c0c0c0, #e5e5e5);
    color: #696969;
    box-shadow: 0 2px 4px rgba(192, 192, 192, 0.3);
}

.ranking-item:nth-child(3) .ranking-item__number {
    background: linear-gradient(45deg, #cd7f32, #daa55a);
    color: #8b4513;
    box-shadow: 0 2px 4px rgba(205, 127, 50, 0.3);
}

.ranking-item:nth-child(n+4) .ranking-item__number {
    background: var(--color-background-dark);
    color: var(--color-text-secondary);
}

.ranking-item__title {
    flex: 1;
    font-size: 14px;
    color: var(--color-text-primary);
    line-height: 1.4;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.ranking-item__value {
    font-size: 12px;
    color: var(--color-text-muted);
    font-weight: 500;
}

統計ウィジェット

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/* 統計ウィジェット */
.widget--stats {
    --stats-grid-gap: var(--spacing-md);
}

.stats-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
    gap: var(--stats-grid-gap);
}

.stats-item {
    text-align: center;
    padding: var(--spacing-md);
    background: var(--color-background);
    border-radius: 8px;
    border: 1px solid var(--color-border-light);
    transition: all 0.3s ease;
}

.stats-item:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 12px var(--color-shadow);
    border-color: var(--color-primary-light);
}

.stats-item__value {
    font-size: 24px;
    font-weight: 700;
    color: var(--color-primary);
    margin-bottom: 4px;
    line-height: 1;
}

.stats-item__label {
    font-size: 12px;
    color: var(--color-text-muted);
    text-transform: uppercase;
    letter-spacing: 0.5px;
    font-weight: 500;
}

.stats-item__change {
    font-size: 11px;
    margin-top: 4px;
    font-weight: 500;
}

.stats-item__change--positive {
    color: var(--color-success);
}

.stats-item__change--negative {
    color: var(--color-danger);
}

.stats-item__change::before {
    content: '';
    display: inline-block;
    width: 0;
    height: 0;
    margin-right: 4px;
    vertical-align: middle;
}

.stats-item__change--positive::before {
    border-left: 4px solid transparent;
    border-right: 4px solid transparent;
    border-bottom: 4px solid var(--color-success);
}

.stats-item__change--negative::before {
    border-left: 4px solid transparent;
    border-right: 4px solid transparent;
    border-top: 4px solid var(--color-danger);
}

3. レスポンシブ対応とバリアント

サイズバリアント

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/* サイズバリアント */
.widget--small {
    --widget-padding: var(--spacing-md);
    --header-font-size: 16px;
    --body-font-size: 13px;
}

.widget--large {
    --widget-padding: var(--spacing-xl);
    --header-font-size: 20px;
    --body-font-size: 16px;
}

/* レスポンシブ対応 */
@media (max-width: 768px) {
    .widget {
        --widget-padding: var(--spacing-md);
        --header-font-size: 16px;
        --body-font-size: 14px;
    }
    
    .stats-grid {
        grid-template-columns: repeat(2, 1fr);
        gap: var(--spacing-sm);
    }
    
    .ranking-item__title {
        font-size: 13px;
    }
}

@media (max-width: 480px) {
    .widget {
        --widget-padding: var(--spacing-md);
        margin-bottom: var(--spacing-md);
        border-radius: 8px;
    }
    
    .stats-grid {
        grid-template-columns: 1fr;
    }
}

カラーバリアント

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* カラーバリアント */
.widget--primary {
    --widget-background: var(--color-primary);
    --header-color: white;
    --body-color: rgba(255, 255, 255, 0.9);
    --widget-border: 1px solid var(--color-primary-dark);
}

.widget--success {
    --widget-background: var(--color-success);
    --header-color: white;
    --body-color: rgba(255, 255, 255, 0.9);
    --widget-border: 1px solid var(--color-success);
}

.widget--warning {
    --widget-background: var(--color-warning);
    --header-color: white;
    --body-color: rgba(255, 255, 255, 0.9);
    --widget-border: 1px solid var(--color-warning);
}

.widget--outline {
    --widget-background: transparent;
    --widget-border: 2px solid var(--color-primary);
    --header-color: var(--color-primary);
    --body-color: var(--color-text-primary);
}

.widget--ghost {
    --widget-background: transparent;
    --widget-border: none;
    --widget-shadow: none;
    --widget-padding: var(--spacing-md);
}

4. 動的コンテンツ対応

ローディング状態

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/* ローディング状態 */
.widget--loading {
    position: relative;
    overflow: hidden;
}

.widget--loading::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(255, 255, 255, 0.8);
    z-index: 10;
}

.widget--loading::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: 24px;
    height: 24px;
    border: 3px solid var(--color-border);
    border-top: 3px solid var(--color-primary);
    border-radius: 50%;
    animation: spin 1s linear infinite;
    z-index: 11;
}

@keyframes spin {
    to { transform: translate(-50%, -50%) rotate(360deg); }
}

/* スケルトン表示 */
.skeleton {
    background: linear-gradient(
        90deg,
        var(--color-background) 25%,
        var(--color-border-light) 50%,
        var(--color-background) 75%
    );
    background-size: 200% 100%;
    animation: skeleton 1.5s infinite;
    border-radius: 4px;
}

@keyframes skeleton {
    0% { background-position: 200% 0; }
    100% { background-position: -200% 0; }
}

.skeleton-text {
    height: 1em;
    margin-bottom: 0.5em;
}

.skeleton-text--short {
    width: 60%;
}

.skeleton-text--medium {
    width: 80%;
}

.skeleton-text--long {
    width: 100%;
}

エラー状態

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* エラー状態 */
.widget--error {
    --widget-border: 1px solid var(--color-danger);
    --widget-background: rgba(231, 76, 60, 0.05);
}

.widget__error {
    text-align: center;
    padding: var(--spacing-lg);
    color: var(--color-danger);
}

.widget__error-icon {
    font-size: 32px;
    margin-bottom: var(--spacing-sm);
}

.widget__error-message {
    font-size: 14px;
    margin-bottom: var(--spacing-md);
}

.widget__error-retry {
    background: var(--color-danger);
    color: white;
    border: none;
    padding: var(--spacing-sm) var(--spacing-md);
    border-radius: 4px;
    cursor: pointer;
    font-size: 12px;
    transition: background-color 0.2s ease;
}

.widget__error-retry:hover {
    background: #c0392b;
}

5. JavaScript連携とインタラクション

ウィジェット基底クラス

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class Widget {
    constructor(element, options = {}) {
        this.element = element;
        this.options = { ...this.constructor.defaults, ...options };
        this.isLoading = false;
        this.hasError = false;
        
        this.init();
    }
    
    static defaults = {
        theme: 'default',
        size: 'medium',
        refreshInterval: null,
        errorRetries: 3
    };
    
    init() {
        this.applyTheme();
        this.setupEventListeners();
        this.load();
        
        if (this.options.refreshInterval) {
            this.startAutoRefresh();
        }
    }
    
    applyTheme() {
        this.element.classList.add(`widget--${this.options.theme}`);
        this.element.classList.add(`widget--${this.options.size}`);
    }
    
    setupEventListeners() {
        // オーバーライド用
    }
    
    setLoading(loading) {
        this.isLoading = loading;
        this.element.classList.toggle('widget--loading', loading);
    }
    
    setError(hasError, message = '') {
        this.hasError = hasError;
        this.element.classList.toggle('widget--error', hasError);
        
        if (hasError) {
            this.renderError(message);
        }
    }
    
    renderError(message) {
        this.element.innerHTML = `
            <div class="widget__error">
                <div class="widget__error-icon">⚠️</div>
                <div class="widget__error-message">${message}</div>
                <button class="widget__error-retry" onclick="this.closest('.widget').widget.retry()">
                    再試行
                </button>
            </div>
        `;
    }
    
    async load() {
        // オーバーライド必須
        throw new Error('load() method must be implemented');
    }
    
    async retry() {
        this.setError(false);
        await this.load();
    }
    
    startAutoRefresh() {
        this.refreshTimer = setInterval(() => {
            if (!this.isLoading && !this.hasError) {
                this.load();
            }
        }, this.options.refreshInterval);
    }
    
    destroy() {
        if (this.refreshTimer) {
            clearInterval(this.refreshTimer);
        }
    }
}

具体的なウィジェット実装例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class NewsWidget extends Widget {
    static defaults = {
        ...Widget.defaults,
        maxItems: 5,
        category: 'all',
        refreshInterval: 300000 // 5分
    };
    
    async load() {
        this.setLoading(true);
        
        try {
            const response = await fetch(`/api/news?category=${this.options.category}&limit=${this.options.maxItems}`);
            const data = await response.json();
            
            this.render(data);
            this.setLoading(false);
        } catch (error) {
            this.setError(true, 'ニュースの取得に失敗しました');
            this.setLoading(false);
        }
    }
    
    render(newsItems) {
        const html = `
            <div class="widget__header">
                <h3>最新ニュース</h3>
                <span class="widget__refresh" onclick="this.closest('.widget').widget.load()">🔄</span>
            </div>
            <div class="widget__body">
                ${newsItems.map(item => `
                    <div class="news-item">
                        <div class="news-item__title">${item.title}</div>
                        <div class="news-item__meta">
                            <span class="news-item__tag">${item.category}</span>
                            <span class="news-item__time">${this.formatTime(item.publishedAt)}</span>
                        </div>
                    </div>
                `).join('')}
            </div>
        `;
        
        this.element.innerHTML = html;
    }
    
    formatTime(timestamp) {
        const date = new Date(timestamp);
        const now = new Date();
        const diffMinutes = Math.floor((now - date) / (1000 * 60));
        
        if (diffMinutes < 60) {
            return `${diffMinutes}分前`;
        } else if (diffMinutes < 1440) {
            return `${Math.floor(diffMinutes / 60)}時間前`;
        } else {
            return date.toLocaleDateString('ja-JP');
        }
    }
}

6. ウィジェットシステムの使用例

HTML構造

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- ニュースウィジェット -->
<div class="widget widget--news" data-widget="news" data-category="tech" data-max-items="5">
    <!-- 動的に生成される -->
</div>

<!-- ランキングウィジェット -->
<div class="widget widget--ranking" data-widget="ranking" data-type="popular">
    <!-- 動的に生成される -->
</div>

<!-- 統計ウィジェット -->
<div class="widget widget--stats widget--primary" data-widget="stats" data-refresh-interval="60000">
    <!-- 動的に生成される -->
</div>

初期化スクリプト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// ウィジェットシステムの初期化
class WidgetSystem {
    constructor() {
        this.widgets = new Map();
        this.widgetTypes = new Map([
            ['news', NewsWidget],
            ['ranking', RankingWidget],
            ['stats', StatsWidget]
        ]);
    }
    
    init() {
        document.querySelectorAll('[data-widget]').forEach(element => {
            this.createWidget(element);
        });
    }
    
    createWidget(element) {
        const widgetType = element.dataset.widget;
        const WidgetClass = this.widgetTypes.get(widgetType);
        
        if (!WidgetClass) {
            console.error(`Unknown widget type: ${widgetType}`);
            return;
        }
        
        const options = { ...element.dataset };
        const widget = new WidgetClass(element, options);
        
        element.widget = widget;
        this.widgets.set(element, widget);
    }
    
    destroyWidget(element) {
        const widget = this.widgets.get(element);
        if (widget) {
            widget.destroy();
            this.widgets.delete(element);
        }
    }
    
    refreshAll() {
        this.widgets.forEach(widget => {
            if (!widget.isLoading) {
                widget.load();
            }
        });
    }
}

// 初期化
document.addEventListener('DOMContentLoaded', () => {
    window.widgetSystem = new WidgetSystem();
    window.widgetSystem.init();
});

まとめ

再利用可能なウィジェットシステムの構築により、保守性とスケーラビリティが大幅に向上します。

主要な設計原則

  • モジュラー設計: 各ウィジェットが独立して動作
  • CSS Variables: テーマとバリアントの柔軟な管理
  • BEM方法論: 予測可能で保守しやすいクラス命名
  • レスポンシブ対応: デバイスに応じた最適な表示

パフォーマンス最適化

  • スケルトン表示によるユーザーエクスペリエンス向上
  • エラーハンドリングと再試行機能
  • 自動リフレッシュとメモリリーク防止
  • 必要に応じたウィジェットの動的生成・破棄

拡張性とメンテナンス

  • 統一されたAPI設計
  • プラグイン形式での新しいウィジェット追加
  • テーマシステムによる一貫したデザイン
  • TypeScript対応による型安全性の確保

次回の記事では、「CSS実践入門第9回:パフォーマンス最適化」について解説予定です。読み込み速度の向上とレンダリング最適化のテクニックを詳しく紹介します。


関連記事:

技術ネタ、趣味や備忘録などを書いているブログです
Hugo で構築されています。
テーマ StackJimmy によって設計されています。