Vue.js で SVG アイコンを自在に扱うテクニック

モダンな Web アプリケーション開発において、アイコンは単なる装飾要素を超えた重要な役割を果たしています。特に Vue.js アプリケーションでは、SVG アイコンを効果的に活用することで、ユーザビリティの向上とパフォーマンスの最適化を同時に実現できます。
今回は、Vue.js で SVG アイコンを自在に扱うための実践的なテクニックをご紹介いたします。基本的な実装から応用的な最適化手法まで、段階的に学んでいけるよう構成しました。
背景
Web アプリケーションにおけるアイコンの重要性
現代の Web アプリケーションにおいて、アイコンはユーザーインターフェースの中核を担う存在となっています。適切に配置されたアイコンは、ユーザーの認知負荷を軽減し、直感的な操作を可能にします。
特に以下の場面でアイコンの重要性が際立ちます:
シーン | アイコンの役割 | 効果 |
---|---|---|
ナビゲーション | 機能の視覚的表現 | 直感的な操作性向上 |
ボタン・CTA | アクションの明確化 | ユーザビリティ向上 |
ステータス表示 | 状態の即座な認識 | 情報伝達の効率化 |
データ表示 | 情報の分類・整理 | 可読性の向上 |
SVG アイコンのメリット
SVG(Scalable Vector Graphics)アイコンは、従来の画像ベースアイコンと比較して多くの利点を持っています。
スケーラビリティ
SVG はベクター形式のため、どのような解像度でも美しく表示されます。高 DPI ディスプレイや Retina 画面でも、鮮明なアイコンを提供できるのです。
javascript<!-- 従来の画像アイコン:解像度に依存 -->
<img src="/icons/heart-16px.png" alt="いいね" />
<img src="/icons/heart-32px.png" alt="いいね" />
<!-- SVGアイコン:一つで全解像度対応 -->
<svg width="16" height="16" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
軽量性
SVG ファイルはテキストベースのため、適切な最適化を行うことで非常に軽量になります。複数の解像度の画像を用意する必要がないため、全体的なファイルサイズの削減に貢献します。
カスタマイズ性
CSS や JavaScript を使用して、色、サイズ、アニメーションなどを動的に変更できます。この特性により、テーマ対応やインタラクティブな表現が容易に実現できるでしょう。
Vue.js で SVG を扱う際の課題
Vue.js アプリケーションで SVG アイコンを活用する際には、いくつかの課題に直面することがあります。これらの課題を理解することで、より効果的な解決策を見つけることができます。
課題
インライン記述による可読性の低下
最も一般的な SVG アイコンの実装方法は、テンプレート内に SVG マークアップを直接記述することです。しかし、この方法には可読性の問題があります。
vue<template>
<div class="user-profile">
<div class="user-info">
<!-- 複雑なSVGマークアップがテンプレートの可読性を損ねる -->
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="7"
r="4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<h3>ユーザー名</h3>
</div>
</div>
</template>
このような実装では、SVG のパスデータがテンプレートの大部分を占め、本来のコンポーネントロジックが埋もれてしまいます。
アイコンの再利用性の問題
同じアイコンを複数箇所で使用する場合、インライン記述では重複したコードが発生します。これは保守性とパフォーマンスの両面で問題となるでしょう。
vue<!-- UserProfile.vue -->
<template>
<div>
<svg width="24" height="24" viewBox="0 0 24 24">
<!-- ユーザーアイコンのSVGパス -->
</svg>
<span>プロフィール</span>
</div>
</template>
<!-- UserList.vue -->
<template>
<div>
<div v-for="user in users" :key="user.id">
<!-- 同じSVGマークアップが重複 -->
<svg width="24" height="24" viewBox="0 0 24 24">
<!-- ユーザーアイコンのSVGパス -->
</svg>
<span>{{ user.name }}</span>
</div>
</div>
</template>
動的な色やサイズ変更の困難さ
アプリケーションの状態やテーマに応じてアイコンの見た目を変更したい場合、インライン記述では柔軟性に欠けます。特に以下のような要件への対応が困難になります:
- ダークモード対応
- アクティブ状態の表示
- サイズのレスポンシブ対応
- アニメーション効果
パフォーマンスへの影響
大量の SVG アイコンをインライン記述で実装すると、以下のパフォーマンス問題が発生する可能性があります:
問題 | 影響 | 発生原因 |
---|---|---|
バンドルサイズの増大 | 初期読み込み時間の延長 | 重複した SVG マークアップ |
DOM サイズの肥大化 | レンダリング性能の低下 | 複雑な SVG パスの大量配置 |
メモリ使用量の増加 | アプリケーション全体の重さ | 同一 SVG の複数インスタンス |
これらの課題を解決するためには、体系的なアプローチが必要です。
解決策
Vue.js で SVG アイコンの課題を解決するには、コンポーネント化とシステマティックな管理が鍵となります。以下に効果的な解決策をご紹介いたします。
SVG アイコンコンポーネント化
最も基本的かつ効果的な解決策は、SVG アイコンを Vue コンポーネントとして実装することです。これにより、再利用性と保守性が大幅に向上します。
基本的なアイコンコンポーネントの作成
まず、シンプルなアイコンコンポーネントを作成してみましょう。
vue<!-- components/icons/UserIcon.vue -->
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
:class="iconClass"
>
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="7"
r="4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<script setup lang="ts">
interface Props {
size?: number | string;
class?: string;
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
class: '',
});
const iconClass = computed(() => {
return `icon-user ${props.class}`;
});
</script>
<style scoped>
.icon-user {
display: inline-block;
vertical-align: middle;
}
</style>
このコンポーネントの使用例:
vue<template>
<div class="user-profile">
<UserIcon :size="32" class="text-blue-500" />
<span>ユーザープロフィール</span>
</div>
</template>
<script setup>
import UserIcon from '@/components/icons/UserIcon.vue';
</script>
プロパティによる動的制御
アイコンコンポーネントにプロパティを追加することで、様々な表示パターンに対応できます。
汎用的なアイコンベースコンポーネント
vue<!-- components/icons/BaseIcon.vue -->
<template>
<svg
:width="size"
:height="size"
:viewBox="viewBox"
:fill="fillColor"
:class="computedClass"
:style="computedStyle"
>
<slot />
</svg>
</template>
<script setup lang="ts">
interface Props {
size?: number | string;
viewBox?: string;
fillColor?: string;
strokeColor?: string;
strokeWidth?: number | string;
class?: string;
variant?: 'default' | 'primary' | 'secondary' | 'danger';
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
viewBox: '0 0 24 24',
fillColor: 'none',
strokeColor: 'currentColor',
strokeWidth: 2,
class: '',
variant: 'default',
});
const computedClass = computed(() => {
return `base-icon base-icon--${props.variant} ${props.class}`;
});
const computedStyle = computed(() => {
return {
'--stroke-color': props.strokeColor,
'--stroke-width': props.strokeWidth,
};
});
</script>
<style scoped>
.base-icon {
display: inline-block;
vertical-align: middle;
stroke: var(--stroke-color);
stroke-width: var(--stroke-width);
transition: all 0.2s ease;
}
.base-icon--primary {
color: #3b82f6;
}
.base-icon--secondary {
color: #6b7280;
}
.base-icon--danger {
color: #ef4444;
}
.base-icon:hover {
transform: scale(1.1);
}
</style>
このベースコンポーネントを使用したアイコン実装:
vue<!-- components/icons/HeartIcon.vue -->
<template>
<BaseIcon v-bind="$props">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</BaseIcon>
</template>
<script setup lang="ts">
import BaseIcon from './BaseIcon.vue';
// BaseIconのpropsを継承
interface Props {
size?: number | string;
viewBox?: string;
fillColor?: string;
strokeColor?: string;
strokeWidth?: number | string;
class?: string;
variant?: 'default' | 'primary' | 'secondary' | 'danger';
}
defineProps<Props>();
</script>
アイコンライブラリの構築
大規模なアプリケーションでは、アイコンを体系的に管理するライブラリシステムが必要です。
アイコンレジストリーシステム
typescript// composables/useIcons.ts
interface IconDefinition {
name: string;
viewBox: string;
paths: string[];
}
const iconRegistry = new Map<string, IconDefinition>();
// アイコン定義の登録
export function registerIcon(
name: string,
definition: IconDefinition
) {
iconRegistry.set(name, definition);
}
// アイコン定義の取得
export function getIcon(
name: string
): IconDefinition | undefined {
return iconRegistry.get(name);
}
// アイコンの一覧取得
export function getIconList(): string[] {
return Array.from(iconRegistry.keys());
}
// 初期アイコンの登録
registerIcon('user', {
name: 'user',
viewBox: '0 0 24 24',
paths: [
'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2',
'M12 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
],
});
registerIcon('heart', {
name: 'heart',
viewBox: '0 0 24 24',
paths: [
'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
],
});
動的アイコンコンポーネント
vue<!-- components/icons/DynamicIcon.vue -->
<template>
<svg
v-if="iconData"
:width="size"
:height="size"
:viewBox="iconData.viewBox"
:class="computedClass"
fill="none"
>
<path
v-for="(path, index) in iconData.paths"
:key="index"
:d="path"
stroke="currentColor"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div v-else class="icon-placeholder">
<span>{{ name }}</span>
</div>
</template>
<script setup lang="ts">
import { getIcon } from '@/composables/useIcons';
interface Props {
name: string;
size?: number | string;
strokeWidth?: number;
class?: string;
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
strokeWidth: 2,
class: '',
});
const iconData = computed(() => {
return getIcon(props.name);
});
const computedClass = computed(() => {
return `dynamic-icon ${props.class}`;
});
</script>
<style scoped>
.dynamic-icon {
display: inline-block;
vertical-align: middle;
}
.icon-placeholder {
display: inline-block;
padding: 4px 8px;
background: #f3f4f6;
border-radius: 4px;
font-size: 12px;
color: #6b7280;
}
</style>
使用例:
vue<template>
<div class="toolbar">
<DynamicIcon
name="user"
:size="20"
class="text-gray-600"
/>
<DynamicIcon
name="heart"
:size="24"
class="text-red-500"
/>
<DynamicIcon name="settings" :size="18" />
</div>
</template>
<script setup>
import DynamicIcon from '@/components/icons/DynamicIcon.vue';
</script>
最適化手法
パフォーマンスを向上させるための最適化テクニックをご紹介します。
SVG スプライトシステム
vue<!-- components/icons/SpriteIcon.vue -->
<template>
<svg :width="size" :height="size" :class="computedClass">
<use :href="`#icon-${name}`" />
</svg>
</template>
<script setup lang="ts">
interface Props {
name: string;
size?: number | string;
class?: string;
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
class: '',
});
const computedClass = computed(() => {
return `sprite-icon ${props.class}`;
});
</script>
<style scoped>
.sprite-icon {
fill: currentColor;
vertical-align: middle;
}
</style>
SVG スプライトの定義:
html<!-- public/icons-sprite.svg -->
<svg
xmlns="http://www.w3.org/2000/svg"
style="display: none;"
>
<symbol id="icon-user" viewBox="0 0 24 24">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</symbol>
<symbol id="icon-heart" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</symbol>
</svg>
遅延読み込みの実装
typescript// composables/useLazyIcons.ts
import { ref } from 'vue';
const loadedIcons = new Set<string>();
const iconCache = new Map<string, string>();
export function useLazyIcon(iconName: string) {
const iconData = ref<string>('');
const loading = ref(false);
const error = ref<Error | null>(null);
async function loadIcon() {
if (loadedIcons.has(iconName)) {
iconData.value = iconCache.get(iconName) || '';
return;
}
loading.value = true;
error.value = null;
try {
const response = await fetch(
`/icons/${iconName}.svg`
);
if (!response.ok) {
throw new Error(
`アイコンの読み込みに失敗しました: ${iconName}`
);
}
const svgContent = await response.text();
iconCache.set(iconName, svgContent);
loadedIcons.add(iconName);
iconData.value = svgContent;
} catch (err) {
error.value =
err instanceof Error
? err
: new Error('不明なエラーが発生しました');
} finally {
loading.value = false;
}
}
return {
iconData,
loading,
error,
loadIcon,
};
}
これらの解決策により、Vue.js アプリケーションでの SVG アイコン管理が格段に改善されます。次に、これらの手法を実際のコードで確認してみましょう。
具体例
実際のアプリケーション開発で活用できる具体的な実装例をご紹介いたします。段階的に複雑さを増していく形で、実用的なコードをお見せします。
基本的な SVG コンポーネント作成
シンプルなアイコンコンポーネント
最初に、基本的なアイコンコンポーネントから始めましょう。
vue<!-- components/icons/StarIcon.vue -->
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
:fill="filled ? 'currentColor' : 'none'"
:class="iconClasses"
@click="handleClick"
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
:stroke="filled ? 'none' : 'currentColor'"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<script setup lang="ts">
interface Props {
size?: number | string;
filled?: boolean;
disabled?: boolean;
class?: string;
}
interface Emits {
click: [event: MouseEvent];
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
filled: false,
disabled: false,
class: '',
});
const emit = defineEmits<Emits>();
const iconClasses = computed(() => {
return [
'star-icon',
{
'star-icon--filled': props.filled,
'star-icon--disabled': props.disabled,
'star-icon--interactive': !props.disabled,
},
props.class,
];
});
function handleClick(event: MouseEvent) {
if (!props.disabled) {
emit('click', event);
}
}
</script>
<style scoped>
.star-icon {
display: inline-block;
vertical-align: middle;
transition: all 0.2s ease;
cursor: default;
}
.star-icon--interactive {
cursor: pointer;
}
.star-icon--interactive:hover {
transform: scale(1.1);
}
.star-icon--filled {
color: #fbbf24;
}
.star-icon--disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
使用例:
vue<template>
<div class="rating">
<StarIcon
v-for="star in 5"
:key="star"
:filled="star <= rating"
:size="20"
@click="setRating(star)"
class="mr-1"
/>
<span class="ml-2 text-gray-600">{{ rating }}/5</span>
</div>
</template>
<script setup>
import { ref } from 'vue';
import StarIcon from '@/components/icons/StarIcon.vue';
const rating = ref(0);
function setRating(value: number) {
rating.value = value;
}
</script>
アニメーション対応アイコン
次に、アニメーション機能を持つアイコンコンポーネントを作成します。
vue<!-- components/icons/LoadingIcon.vue -->
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
:class="iconClasses"
>
<circle
class="loading-circle-bg"
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
stroke-width="2"
opacity="0.25"
/>
<circle
class="loading-circle-progress"
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
:style="progressStyle"
/>
</svg>
</template>
<script setup lang="ts">
interface Props {
size?: number | string;
progress?: number; // 0-100の進捗値
spinning?: boolean;
class?: string;
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
progress: 0,
spinning: false,
class: '',
});
const iconClasses = computed(() => {
return [
'loading-icon',
{
'loading-icon--spinning': props.spinning,
},
props.class,
];
});
const progressStyle = computed(() => {
const circumference = 2 * Math.PI * 10;
const strokeDasharray = circumference;
const strokeDashoffset =
circumference - (props.progress / 100) * circumference;
return {
strokeDasharray: `${strokeDasharray}`,
strokeDashoffset: `${strokeDashoffset}`,
transform: 'rotate(-90deg)',
transformOrigin: '50% 50%',
};
});
</script>
<style scoped>
.loading-icon {
display: inline-block;
vertical-align: middle;
}
.loading-icon--spinning {
animation: spin 1s linear infinite;
}
.loading-circle-progress {
transition: stroke-dashoffset 0.3s ease;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
アイコンセット管理システム
大規模なアプリケーションに対応するアイコン管理システムを構築します。
アイコン定義ファイル
typescript// types/icons.ts
export interface IconDefinition {
name: string;
viewBox: string;
paths: Array<{
d: string;
fill?: string;
stroke?: string;
strokeWidth?: number;
}>;
category?: string;
keywords?: string[];
}
export interface IconSet {
name: string;
version: string;
icons: IconDefinition[];
}
typescript// data/icons.ts
import type { IconSet } from '@/types/icons';
export const iconSet: IconSet = {
name: 'app-icons',
version: '1.0.0',
icons: [
{
name: 'home',
viewBox: '0 0 24 24',
paths: [
{
d: 'M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
},
{
d: 'M9 22V12h6v10',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
},
],
category: 'navigation',
keywords: ['house', 'main', 'start'],
},
{
name: 'mail',
viewBox: '0 0 24 24',
paths: [
{
d: 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
},
{
d: 'M22 6l-10 7L2 6',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
},
],
category: 'communication',
keywords: ['email', 'message', 'envelope'],
},
{
name: 'settings',
viewBox: '0 0 24 24',
paths: [
{
d: 'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 000 2l.08.15a2 2 0 010 2l-.08.15a2 2 0 000 2l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 000-2l-.08-.15a2 2 0 010-2l.08-.15a2 2 0 000-2l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
},
{
d: 'M12 15a3 3 0 100-6 3 3 0 000 6z',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
},
],
category: 'interface',
keywords: ['config', 'preferences', 'options'],
},
],
};
アイコンマネージャー Composable
typescript// composables/useIconManager.ts
import { iconSet } from '@/data/icons';
import type { IconDefinition } from '@/types/icons';
class IconManager {
private icons = new Map<string, IconDefinition>();
private categories = new Map<string, IconDefinition[]>();
constructor() {
this.loadIconSet();
}
private loadIconSet() {
iconSet.icons.forEach((icon) => {
this.icons.set(icon.name, icon);
const category = icon.category || 'uncategorized';
if (!this.categories.has(category)) {
this.categories.set(category, []);
}
this.categories.get(category)!.push(icon);
});
}
getIcon(name: string): IconDefinition | undefined {
return this.icons.get(name);
}
getAllIcons(): IconDefinition[] {
return Array.from(this.icons.values());
}
getIconsByCategory(category: string): IconDefinition[] {
return this.categories.get(category) || [];
}
getCategories(): string[] {
return Array.from(this.categories.keys());
}
searchIcons(query: string): IconDefinition[] {
const lowercaseQuery = query.toLowerCase();
return this.getAllIcons().filter((icon) => {
return (
icon.name.toLowerCase().includes(lowercaseQuery) ||
icon.keywords?.some((keyword) =>
keyword.toLowerCase().includes(lowercaseQuery)
)
);
});
}
registerIcon(icon: IconDefinition) {
this.icons.set(icon.name, icon);
const category = icon.category || 'uncategorized';
if (!this.categories.has(category)) {
this.categories.set(category, []);
}
this.categories.get(category)!.push(icon);
}
}
const iconManager = new IconManager();
export function useIconManager() {
return {
getIcon: (name: string) => iconManager.getIcon(name),
getAllIcons: () => iconManager.getAllIcons(),
getIconsByCategory: (category: string) =>
iconManager.getIconsByCategory(category),
getCategories: () => iconManager.getCategories(),
searchIcons: (query: string) =>
iconManager.searchIcons(query),
registerIcon: (icon: IconDefinition) =>
iconManager.registerIcon(icon),
};
}
統合アイコンコンポーネント
vue<!-- components/icons/Icon.vue -->
<template>
<svg
v-if="iconData"
:width="size"
:height="size"
:viewBox="iconData.viewBox"
:class="computedClasses"
:style="computedStyles"
v-bind="$attrs"
>
<path
v-for="(path, index) in iconData.paths"
:key="index"
:d="path.d"
:fill="path.fill || fillColor"
:stroke="path.stroke || strokeColor"
:stroke-width="path.strokeWidth || strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div v-else-if="showPlaceholder" class="icon-placeholder">
{{ name }}
</div>
</template>
<script setup lang="ts">
import { useIconManager } from '@/composables/useIconManager';
interface Props {
name: string;
size?: number | string;
fillColor?: string;
strokeColor?: string;
strokeWidth?: number;
variant?:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'danger';
class?: string;
showPlaceholder?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
fillColor: 'none',
strokeColor: 'currentColor',
strokeWidth: 2,
variant: 'default',
class: '',
showPlaceholder: true,
});
const { getIcon } = useIconManager();
const iconData = computed(() => {
return getIcon(props.name);
});
const computedClasses = computed(() => {
return ['icon', `icon--${props.variant}`, props.class];
});
const computedStyles = computed(() => {
const variantColors = {
default: 'currentColor',
primary: '#3b82f6',
secondary: '#6b7280',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
};
return {
color: variantColors[props.variant],
};
});
</script>
<style scoped>
.icon {
display: inline-block;
vertical-align: middle;
flex-shrink: 0;
}
.icon-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
border: 1px dashed #d1d5db;
border-radius: 4px;
color: #6b7280;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
</style>
動的スタイリング実装
テーマ対応アイコンシステム
typescript// composables/useTheme.ts
export interface Theme {
name: string;
colors: {
primary: string;
secondary: string;
success: string;
warning: string;
danger: string;
background: string;
foreground: string;
};
}
const themes: Record<string, Theme> = {
light: {
name: 'light',
colors: {
primary: '#3b82f6',
secondary: '#6b7280',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
background: '#ffffff',
foreground: '#1f2937',
},
},
dark: {
name: 'dark',
colors: {
primary: '#60a5fa',
secondary: '#9ca3af',
success: '#34d399',
warning: '#fbbf24',
danger: '#f87171',
background: '#1f2937',
foreground: '#f9fafb',
},
},
};
export function useTheme() {
const currentTheme = ref<string>('light');
const theme = computed(() => themes[currentTheme.value]);
function setTheme(themeName: string) {
if (themes[themeName]) {
currentTheme.value = themeName;
updateCSSVariables(themes[themeName]);
}
}
function updateCSSVariables(theme: Theme) {
const root = document.documentElement;
Object.entries(theme.colors).forEach(([key, value]) => {
root.style.setProperty(`--color-${key}`, value);
});
}
// 初期テーマの適用
onMounted(() => {
updateCSSVariables(theme.value);
});
return {
currentTheme: readonly(currentTheme),
theme: readonly(theme),
setTheme,
availableThemes: Object.keys(themes),
};
}
レスポンシブ対応アイコン
vue<!-- components/icons/ResponsiveIcon.vue -->
<template>
<Icon
:name="name"
:size="responsiveSize"
:class="responsiveClasses"
v-bind="$attrs"
/>
</template>
<script setup lang="ts">
import Icon from './Icon.vue';
interface Props {
name: string;
sizes?: {
sm?: number;
md?: number;
lg?: number;
xl?: number;
};
class?: string;
}
const props = withDefaults(defineProps<Props>(), {
sizes: () => ({
sm: 16,
md: 20,
lg: 24,
xl: 32,
}),
class: '',
});
const windowWidth = ref(0);
// ウィンドウサイズの監視
function updateWindowWidth() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
updateWindowWidth();
window.addEventListener('resize', updateWindowWidth);
});
onUnmounted(() => {
window.removeEventListener('resize', updateWindowWidth);
});
const responsiveSize = computed(() => {
if (windowWidth.value >= 1280) {
return props.sizes.xl || 32;
} else if (windowWidth.value >= 1024) {
return props.sizes.lg || 24;
} else if (windowWidth.value >= 768) {
return props.sizes.md || 20;
} else {
return props.sizes.sm || 16;
}
});
const responsiveClasses = computed(() => {
const breakpoint =
windowWidth.value >= 1280
? 'xl'
: windowWidth.value >= 1024
? 'lg'
: windowWidth.value >= 768
? 'md'
: 'sm';
return [
'responsive-icon',
`responsive-icon--${breakpoint}`,
props.class,
];
});
</script>
<style scoped>
.responsive-icon {
transition: all 0.2s ease;
}
@media (max-width: 640px) {
.responsive-icon--sm {
transform: scale(0.9);
}
}
@media (min-width: 1280px) {
.responsive-icon--xl {
transform: scale(1.1);
}
}
</style>
TypeScript 対応
型安全なアイコンシステム
typescript// types/icon-names.ts
export type IconName =
| 'home'
| 'mail'
| 'settings'
| 'user'
| 'heart'
| 'star'
| 'search'
| 'menu'
| 'close'
| 'arrow-right'
| 'arrow-left'
| 'arrow-up'
| 'arrow-down'
| 'check'
| 'x'
| 'plus'
| 'minus'
| 'edit'
| 'delete'
| 'save'
| 'cancel'
| 'loading'
| 'warning'
| 'error'
| 'success'
| 'info';
export interface IconProps {
name: IconName;
size?: number | string;
variant?:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'danger';
class?: string;
}
型安全なコンポーネント定義
vue<!-- components/icons/TypeSafeIcon.vue -->
<template>
<Icon
:name="name"
:size="size"
:variant="variant"
:class="class"
v-bind="$attrs"
/>
</template>
<script setup lang="ts">
import Icon from './Icon.vue';
import type {
IconName,
IconProps,
} from '@/types/icon-names';
// 型安全なProps定義
interface Props extends IconProps {
name: IconName; // 型制約により、存在しないアイコン名を防ぐ
}
defineProps<Props>();
</script>
アイコン名の自動補完サポート
typescript// utils/icon-helpers.ts
import type { IconName } from '@/types/icon-names';
// アイコン名のバリデーション
export function isValidIconName(
name: string
): name is IconName {
const validNames: IconName[] = [
'home',
'mail',
'settings',
'user',
'heart',
'star',
'search',
'menu',
'close',
'arrow-right',
'arrow-left',
'arrow-up',
'arrow-down',
'check',
'x',
'plus',
'minus',
'edit',
'delete',
'save',
'cancel',
'loading',
'warning',
'error',
'success',
'info',
];
return validNames.includes(name as IconName);
}
// 開発時のアイコン名チェック
export function validateIconName(name: string): void {
if (!isValidIconName(name)) {
console.warn(
`Invalid icon name: ${name}. Available icons:`,
getAvailableIconNames()
);
}
}
export function getAvailableIconNames(): IconName[] {
return [
'home',
'mail',
'settings',
'user',
'heart',
'star',
'search',
'menu',
'close',
'arrow-right',
'arrow-left',
'arrow-up',
'arrow-down',
'check',
'x',
'plus',
'minus',
'edit',
'delete',
'save',
'cancel',
'loading',
'warning',
'error',
'success',
'info',
];
}
これらの具体例により、Vue.js アプリケーションで SVG アイコンを効率的に管理し、活用できるシステムが構築できます。型安全性とパフォーマンスを両立した実装となっています。
まとめ
Vue.js で SVG アイコンを自在に扱うための様々なテクニックをご紹介いたしました。基本的なコンポーネント化から始まり、動的制御、ライブラリ構築、最適化手法まで段階的に解説いたしました。
重要なポイントの振り返り
コンポーネント化のメリット
SVG アイコンを Vue コンポーネント化することで得られる主な利点:
メリット | 詳細 | 効果 |
---|---|---|
再利用性 | 一度作成したアイコンを複数箇所で使用可能 | 開発効率の向上 |
保守性 | アイコンの修正が一箇所で完結 | メンテナンスコストの削減 |
型安全性 | TypeScript による型チェック | バグの早期発見 |
カスタマイズ性 | プロパティによる柔軟な制御 | デザインシステムとの整合 |
パフォーマンス最適化の効果
適切な最適化手法により、以下の改善が期待できます:
- バンドルサイズの削減(平均 30-50%の軽量化)
- 初回読み込み時間の短縮
- レンダリング性能の向上
- メモリ使用量の最適化
実装時の推奨手順
- 基本コンポーネントの作成 - シンプルなアイコンコンポーネントから開始
- プロパティ設計 - 柔軟性と使いやすさのバランスを考慮
- ライブラリ構築 - アイコンの体系的管理システムを導入
- 最適化実装 - パフォーマンス要件に応じた最適化手法を選択
- TypeScript 対応 - 型安全性の確保と開発体験の向上
今後の展望
SVG アイコンシステムは、以下の方向で更なる発展が期待されます:
自動化の推進
- デザインファイルからのアイコン自動生成
- CI/CD パイプラインでの最適化処理
- アクセシビリティチェックの自動化
開発体験の向上
- IDE 拡張による視覚的なアイコン選択
- リアルタイムプレビュー機能
- 自動補完とドキュメント生成
パフォーマンス最適化
- WebAssembly を活用した高速処理
- Service Worker による高度なキャッシング
- HTTP/3 対応による配信最適化
Vue.js での SVG アイコン活用は、適切な設計と実装により、アプリケーションの品質向上に大きく貢献します。本記事でご紹介したテクニックを参考に、プロジェクトに最適なアイコンシステムを構築していただければと思います。
継続的な改善と最新技術の導入により、より良いユーザーエクスペリエンスの実現を目指してください。
関連リンク
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来