Vue3デザインシステム実践:ヘッドレスコンポーネントからDesign Tokenまでの5つのプロダクションパターン
2026年にVue3デザインシステムが必要な理由
2026年、フロントエンドエンジニアリングは深水区に入っています。チームが3人から30人に成長し、プロジェクトが1つから10に拡大すると、デザインシステムのないチームはスタイルの断片化とコンポーネントの再発明に苦しむ運命にあります。Vue3のComposition APIはデザインシステムアーキテクチャに自然に適合します — Composableがロジック層を、Design Tokenがビジュアル層を、コンポーネントがプレゼンテーション層を提供し、3つの層が直交的に組み合わさります。
| 次元 | デザインシステムなし | デザインシステムあり |
|---|---|---|
| ボタンスタイル | 17種類の異なるボタン | 1つの統一ボタン |
| テーマ切り替え | コンポーネントごとにハードコード | CSS変数でワンクリック切替 |
| コンポーネント再利用 | コピペコード | npm install |
| デザイン一貫性 | デザイナーが毎日議論 | Token駆動の整合 |
| バンドルサイズ | 重複コードの膨張 | Tree-shaking最適化 |
| オンボーディングコスト | 人により異なる | 統一API規約 |
核心的な見解:デザインシステムはUIフレームワークではなく、設計判断のコード化です。ヘッドレスコンポーネントはロジックとUIを分離し、Design Tokenはビジュアルとコンポーネントを分離し、複合コンポーネントは合成と実装を分離します。
パターン1:ヘッドレスコンポーネントとComposable
解決する問題:コンポーネントロジックがUI表現と深く結合しており、UIを変えるたびにロジックを書き直す必要がある。ヘッドレスコンポーネントは状態管理、キーボードインタラクション、アクセシビリティロジックをComposableに抽出し、UI層を完全に消費者の制御に委ねます。
useDialog — ダイアログロジック
import { ref, computed, watch, onMounted, onUnmounted, type Ref } from 'vue'
interface UseDialogOptions {
initialOpen?: boolean
closeOnEsc?: boolean
closeOnOverlay?: boolean
preventScroll?: boolean
onOpen?: () => void
onClose?: () => void
}
interface UseDialogReturn {
isOpen: Ref<boolean>
open: () => void
close: () => void
toggle: () => void
overlayProps: () => {
onClick: () => void
'aria-hidden': boolean
}
triggerProps: () => {
onClick: () => void
'aria-haspopup': 'dialog'
'aria-expanded': boolean
}
dialogProps: () => {
role: 'dialog'
'aria-modal': boolean
'aria-label'?: string
}
}
export function useDialog(options: UseDialogOptions = {}): UseDialogReturn {
const {
initialOpen = false,
closeOnEsc = true,
closeOnOverlay = true,
preventScroll = true,
onOpen,
onClose
} = options
const isOpen = ref(initialOpen)
const originalOverflow = ref('')
const open = (): void => {
isOpen.value = true
onOpen?.()
}
const close = (): void => {
isOpen.value = false
onClose?.()
}
const toggle = (): void => {
isOpen.value ? close() : open()
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (closeOnEsc && event.key === 'Escape' && isOpen.value) {
close()
}
}
watch(isOpen, (value) => {
if (preventScroll) {
if (value) {
originalOverflow.value = document.body.style.overflow
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = originalOverflow.value
}
}
})
onMounted(() => {
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
if (preventScroll && isOpen.value) {
document.body.style.overflow = originalOverflow.value
}
})
const overlayProps = () => ({
onClick: closeOnOverlay ? close : () => {},
'aria-hidden': !isOpen.value
})
const triggerProps = () => ({
onClick: toggle,
'aria-haspopup': 'dialog' as const,
'aria-expanded': isOpen.value
})
const dialogProps = () => ({
role: 'dialog' as const,
'aria-modal': isOpen.value,
'aria-label': 'Dialog'
})
return { isOpen, open, close, toggle, overlayProps, triggerProps, dialogProps }
}
useSelect — セレクトロジック
import { ref, computed, onMounted, onUnmounted, type Ref, type ComputedRef } from 'vue'
interface SelectOption {
value: string | number
label: string
disabled?: boolean
}
interface UseSelectOptions {
options: Ref<SelectOption[]>
modelValue?: Ref<string | number | undefined>
multiple?: boolean
searchable?: boolean
disabled?: boolean
onChange?: (value: string | number | (string | number)[]) => void
}
interface UseSelectReturn {
isOpen: Ref<boolean>
selectedValue: Ref<string | number | undefined>
highlightedIndex: Ref<number>
searchQuery: Ref<string>
displayLabel: ComputedRef<string>
filteredOptions: ComputedRef<SelectOption[]>
open: () => void
close: () => void
toggle: () => void
select: (option: SelectOption) => void
highlightNext: () => void
highlightPrev: () => void
confirmHighlight: () => void
triggerProps: () => Record<string, any>
listboxProps: () => Record<string, any>
}
export function useSelect(options: UseSelectOptions): UseSelectReturn {
const {
options: optionsRef,
modelValue,
multiple = false,
searchable = false,
disabled = false,
onChange
} = options
const isOpen = ref(false)
const selectedValue = modelValue ?? ref<string | number | undefined>(undefined)
const highlightedIndex = ref(0)
const searchQuery = ref('')
const filteredOptions = computed(() => {
if (!searchable || !searchQuery.value) return optionsRef.value
const query = searchQuery.value.toLowerCase()
return optionsRef.value.filter((opt) =>
opt.label.toLowerCase().includes(query)
)
})
const displayLabel = computed(() => {
const selected = optionsRef.value.find((opt) => opt.value === selectedValue.value)
return selected?.label ?? ''
})
const open = (): void => {
if (disabled) return
isOpen.value = true
highlightedIndex.value = 0
}
const close = (): void => {
isOpen.value = false
searchQuery.value = ''
}
const toggle = (): void => {
isOpen.value ? close() : open()
}
const select = (option: SelectOption): void => {
if (option.disabled) return
selectedValue.value = option.value
onChange?.(option.value)
if (!multiple) close()
}
const highlightNext = (): void => {
if (highlightedIndex.value < filteredOptions.value.length - 1) {
highlightedIndex.value++
}
}
const highlightPrev = (): void => {
if (highlightedIndex.value > 0) {
highlightedIndex.value--
}
}
const confirmHighlight = (): void => {
const option = filteredOptions.value[highlightedIndex.value]
if (option) select(option)
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (!isOpen.value) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
open()
}
return
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
highlightNext()
break
case 'ArrowUp':
event.preventDefault()
highlightPrev()
break
case 'Enter':
event.preventDefault()
confirmHighlight()
break
case 'Escape':
close()
break
default:
if (searchable && event.key.length === 1) {
searchQuery.value += event.key
}
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
})
const triggerProps = () => ({
onClick: toggle,
'aria-haspopup': 'listbox',
'aria-expanded': isOpen.value,
'aria-disabled': disabled,
role: 'combobox'
})
const listboxProps = () => ({
role: 'listbox',
'aria-activedescendant': `option-${highlightedIndex.value}`
})
return {
isOpen, selectedValue, highlightedIndex, searchQuery,
displayLabel, filteredOptions, open, close, toggle,
select, highlightNext, highlightPrev, confirmHighlight,
triggerProps, listboxProps
}
}
useTabs — タブロジック
import { ref, computed, type Ref, type ComputedRef } from 'vue'
interface TabItem {
key: string
label: string
disabled?: boolean
}
interface UseTabsOptions {
tabs: Ref<TabItem[]>
initialActiveKey?: string
onChange?: (activeKey: string) => void
}
interface UseTabsReturn {
activeKey: Ref<string>
activeIndex: ComputedRef<number>
isActive: (key: string) => boolean
activate: (key: string) => void
activateNext: () => void
activatePrev: () => void
activateFirst: () => void
activateLast: () => void
tabListProps: () => Record<string, any>
getTabProps: (key: string) => Record<string, any>
getPanelProps: (key: string) => Record<string, any>
}
export function useTabs(options: UseTabsOptions): UseTabsReturn {
const { tabs: tabsRef, initialActiveKey, onChange } = options
const enabledTabs = computed(() => tabsRef.value.filter((t) => !t.disabled))
const activeKey = ref(
initialActiveKey ?? enabledTabs.value[0]?.key ?? ''
)
const activeIndex = computed(() =>
tabsRef.value.findIndex((t) => t.key === activeKey.value)
)
const isActive = (key: string): boolean => activeKey.value === key
const activate = (key: string): void => {
const tab = tabsRef.value.find((t) => t.key === key)
if (tab && !tab.disabled) {
activeKey.value = key
onChange?.(key)
}
}
const activateNext = (): void => {
const currentIdx = enabledTabs.value.findIndex((t) => t.key === activeKey.value)
const nextIdx = (currentIdx + 1) % enabledTabs.value.length
activate(enabledTabs.value[nextIdx].key)
}
const activatePrev = (): void => {
const currentIdx = enabledTabs.value.findIndex((t) => t.key === activeKey.value)
const prevIdx = (currentIdx - 1 + enabledTabs.value.length) % enabledTabs.value.length
activate(enabledTabs.value[prevIdx].key)
}
const activateFirst = (): void => {
if (enabledTabs.value.length > 0) activate(enabledTabs.value[0].key)
}
const activateLast = (): void => {
if (enabledTabs.value.length > 0) {
activate(enabledTabs.value[enabledTabs.value.length - 1].key)
}
}
const tabListProps = () => ({
role: 'tablist',
'aria-orientation': 'horizontal' as const,
onKeyDown: (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowRight': event.preventDefault(); activateNext(); break
case 'ArrowLeft': event.preventDefault(); activatePrev(); break
case 'Home': event.preventDefault(); activateFirst(); break
case 'End': event.preventDefault(); activateLast(); break
}
}
})
const getTabProps = (key: string) => ({
role: 'tab',
'aria-selected': isActive(key),
'aria-controls': `panel-${key}`,
id: `tab-${key}`,
tabIndex: isActive(key) ? 0 : -1,
onClick: () => activate(key)
})
const getPanelProps = (key: string) => ({
role: 'tabpanel',
id: `panel-${key}`,
'aria-labelledby': `tab-${key}`,
hidden: !isActive(key),
tabIndex: 0
})
return {
activeKey, activeIndex, isActive, activate,
activateNext, activatePrev, activateFirst, activateLast,
tabListProps, getTabProps, getPanelProps
}
}
使用例 — 完全カスタムUI:
<script setup lang="ts">
import { ref } from 'vue'
import { useDialog } from '@/composables/useDialog'
import { useSelect } from '@/composables/useSelect'
import { useTabs } from '@/composables/useTabs'
const { isOpen, open, close, overlayProps, triggerProps, dialogProps } = useDialog({
closeOnEsc: true,
closeOnOverlay: true
})
const options = ref([
{ value: 'vue', label: 'Vue3' },
{ value: 'react', label: 'React' },
{ value: 'svelte', label: 'Svelte' }
])
const { selectedValue, displayLabel, isOpen: selectOpen, filteredOptions, highlightedIndex, triggerProps: selectTriggerProps, listboxProps, select } = useSelect({ options })
const tabs = ref([
{ key: 'overview', label: '概要' },
{ key: 'api', label: 'API' },
{ key: 'examples', label: '例' }
])
const { activeKey, tabListProps, getTabProps, getPanelProps } = useTabs({ tabs })
</script>
<template>
<button v-bind="triggerProps()">ダイアログを開く</button>
<div v-if="isOpen" v-bind="overlayProps()" class="fixed inset-0 bg-black/50">
<div v-bind="dialogProps()" class="bg-white p-6 rounded-lg">
<h2>カスタムダイアログUI</h2>
<button @click="close">閉じる</button>
</div>
</div>
<div v-bind="tabListProps()" class="flex gap-2">
<button v-for="tab in tabs" :key="tab.key" v-bind="getTabProps(tab.key)"
:class="['px-4 py-2', activeKey === tab.key ? 'border-b-2 border-blue-500' : '']">
{{ tab.label }}
</button>
</div>
<div v-for="tab in tabs" :key="tab.key" v-bind="getPanelProps(tab.key)">
<p>{{ tab.label }}コンテンツエリア</p>
</div>
</template>
パターン2:Design TokenとCSSカスタムプロパティ
解決する問題:色、間隔、角丸などの視覚プロパティが各コンポーネントに散在し、テーマ切り替えには個別の修正が必要。Design Tokenは視覚判断を一元管理し、CSSカスタムプロパティでランタイムテーマ切り替えを実現します。
Token体系アーキテクチャ
interface DesignTokenGroup {
color: Record<string, string>
spacing: Record<string, string>
radius: Record<string, string>
typography: Record<string, string>
shadow: Record<string, string>
transition: Record<string, string>
breakpoint: Record<string, string>
zIndex: Record<string, string>
}
const lightTokens: DesignTokenGroup = {
color: {
primary: '#3b82f6',
'primary-hover': '#2563eb',
'primary-active': '#1d4ed8',
'primary-light': '#eff6ff',
secondary: '#64748b',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
'bg-base': '#ffffff',
'bg-elevated': '#f8fafc',
'bg-sunken': '#f1f5f9',
'text-primary': '#0f172a',
'text-secondary': '#475569',
'text-tertiary': '#94a3b8',
'text-inverse': '#ffffff',
'border-default': '#e2e8f0',
'border-strong': '#cbd5e1'
},
spacing: {
'0': '0px',
'1': '4px',
'2': '8px',
'3': '12px',
'4': '16px',
'5': '20px',
'6': '24px',
'8': '32px',
'10': '40px',
'12': '48px',
'16': '64px'
},
radius: {
none: '0px',
sm: '4px',
md: '8px',
lg: '12px',
xl: '16px',
full: '9999px'
},
typography: {
'font-sans': 'Inter, system-ui, -apple-system, sans-serif',
'font-mono': 'JetBrains Mono, Menlo, monospace',
'text-xs': '0.75rem',
'text-sm': '0.875rem',
'text-base': '1rem',
'text-lg': '1.125rem',
'text-xl': '1.25rem',
'text-2xl': '1.5rem',
'text-3xl': '1.875rem',
'leading-tight': '1.25',
'leading-normal': '1.5',
'leading-relaxed': '1.75'
},
shadow: {
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1)'
},
transition: {
fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)',
normal: '250ms cubic-bezier(0.4, 0, 0.2, 1)',
slow: '350ms cubic-bezier(0.4, 0, 0.2, 1)'
},
breakpoint: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px'
},
zIndex: {
dropdown: '1000',
sticky: '1020',
overlay: '1030',
modal: '1040',
popover: '1050',
toast: '1060'
}
}
const darkTokens: DesignTokenGroup = {
...lightTokens,
color: {
primary: '#60a5fa',
'primary-hover': '#93bbfd',
'primary-active': '#3b82f6',
'primary-light': '#1e3a5f',
secondary: '#94a3b8',
success: '#4ade80',
warning: '#fbbf24',
error: '#f87171',
'bg-base': '#0f172a',
'bg-elevated': '#1e293b',
'bg-sunken': '#020617',
'text-primary': '#f1f5f9',
'text-secondary': '#94a3b8',
'text-tertiary': '#64748b',
'text-inverse': '#0f172a',
'border-default': '#334155',
'border-strong': '#475569'
}
}
テーマ管理Composable
import { ref, watchEffect, type Ref } from 'vue'
type ThemeMode = 'light' | 'dark' | 'system'
interface UseThemeOptions {
storageKey?: string
defaultMode?: ThemeMode
}
interface UseThemeReturn {
mode: Ref<ThemeMode>
resolvedMode: Ref<'light' | 'dark'>
setMode: (mode: ThemeMode) => void
toggleMode: () => void
tokens: Ref<DesignTokenGroup>
}
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function applyTokensToDOM(tokens: DesignTokenGroup): void {
const root = document.documentElement
for (const [group, values] of Object.entries(tokens)) {
for (const [key, value] of Object.entries(values)) {
root.style.setProperty(`--${group}-${key}`, value)
}
}
}
export function useTheme(options: UseThemeOptions = {}): UseThemeReturn {
const { storageKey = 'toolsku-theme', defaultMode = 'system' } = options
const stored = typeof localStorage !== 'undefined'
? (localStorage.getItem(storageKey) as ThemeMode | null)
: null
const mode = ref<ThemeMode>(stored ?? defaultMode)
const resolvedMode = ref<'light' | 'dark'>(
mode.value === 'system' ? getSystemTheme() : mode.value
)
const tokens = ref<DesignTokenGroup>(
resolvedMode.value === 'dark' ? darkTokens : lightTokens
)
const setMode = (newMode: ThemeMode): void => {
mode.value = newMode
if (typeof localStorage !== 'undefined') {
localStorage.setItem(storageKey, newMode)
}
}
const toggleMode = (): void => {
const next: Record<ThemeMode, ThemeMode> = {
light: 'dark',
dark: 'system',
system: 'light'
}
setMode(next[mode.value])
}
watchEffect(() => {
resolvedMode.value = mode.value === 'system' ? getSystemTheme() : mode.value
tokens.value = resolvedMode.value === 'dark' ? darkTokens : lightTokens
applyTokensToDOM(tokens.value)
document.documentElement.setAttribute('data-theme', resolvedMode.value)
})
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => {
if (mode.value === 'system') {
resolvedMode.value = getSystemTheme()
}
}
mediaQuery.addEventListener('change', handleChange)
}
return { mode, resolvedMode, setMode, toggleMode, tokens }
}
Tailwind CSSでDesign Tokenを使用
@layer base {
:root {
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-bg-base: #ffffff;
--color-text-primary: #0f172a;
--spacing-4: 16px;
--radius-md: 8px;
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
[data-theme="dark"] {
--color-primary: #60a5fa;
--color-primary-hover: #93bbfd;
--color-bg-base: #0f172a;
--color-text-primary: #f1f5f9;
}
}
import type { Config } from 'tailwindcss'
const config: Config = {
theme: {
extend: {
colors: {
primary: 'var(--color-primary)',
'primary-hover': 'var(--color-primary-hover)',
'bg-base': 'var(--color-bg-base)',
'text-primary': 'var(--color-text-primary)'
},
spacing: {
'ds-1': 'var(--spacing-1)',
'ds-2': 'var(--spacing-2)',
'ds-4': 'var(--spacing-4)',
'ds-6': 'var(--spacing-6)',
'ds-8': 'var(--spacing-8)'
},
borderRadius: {
ds: 'var(--radius-md)'
},
boxShadow: {
ds: 'var(--shadow-md)'
},
transitionDuration: {
ds: 'var(--transition-normal)'
}
}
}
}
export default config
パターン3:複合コンポーネント(Compound Components)
解決する問題:AccordionやDataTableなどのコンポーネントは複数の関連サブコンポーネントで構成され、親子間で状態を共有する必要があるが、propsを段階的に渡したくない。複合コンポーネントはprovide/injectで暗黙的な状態共有を実現します。
Accordion複合コンポーネント
import { ref, provide, inject, computed, type Ref, type InjectionKey } from 'vue'
interface AccordionItemState {
key: string
isOpen: Ref<boolean>
toggle: () => void
headerProps: () => Record<string, any>
panelProps: () => Record<string, any>
}
interface AccordionContext {
activeKeys: Ref<Set<string>>
multiple: boolean
toggleItem: (key: string) => void
registerItem: (key: string) => void
unregisterItem: (key: string) => void
}
const ACCORDION_KEY: InjectionKey<AccordionContext> = Symbol('accordion')
export function useAccordionProvider(options: {
multiple?: boolean
initialActiveKeys?: string[]
onChange?: (keys: string[]) => void
} = {}) {
const { multiple = false, initialActiveKeys = [], onChange } = options
const activeKeys = ref<Set<string>>(new Set(initialActiveKeys))
const toggleItem = (key: string): void => {
const newKeys = new Set(activeKeys.value)
if (newKeys.has(key)) {
newKeys.delete(key)
} else {
if (!multiple) newKeys.clear()
newKeys.add(key)
}
activeKeys.value = newKeys
onChange?.(Array.from(newKeys))
}
const registerItem = (_key: string): void => {}
const unregisterItem = (key: string): void => {
const newKeys = new Set(activeKeys.value)
newKeys.delete(key)
activeKeys.value = newKeys
}
const context: AccordionContext = { activeKeys, multiple, toggleItem, registerItem, unregisterItem }
provide(ACCORDION_KEY, context)
return context
}
export function useAccordionItem(key: string): AccordionItemState {
const context = inject(ACCORDION_KEY)
if (!context) throw new Error('useAccordionItem must be used inside Accordion')
const isOpen = computed(() => context.activeKeys.value.has(key))
const toggle = (): void => context.toggleItem(key)
const headerProps = () => ({
id: `accordion-header-${key}`,
'aria-controls': `accordion-panel-${key}`,
'aria-expanded': isOpen.value,
role: 'button',
tabIndex: 0,
onClick: toggle,
onKeyDown: (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle()
}
}
})
const panelProps = () => ({
id: `accordion-panel-${key}`,
'aria-labelledby': `accordion-header-${key}`,
role: 'region',
hidden: !isOpen.value
})
return { key, isOpen, toggle, headerProps, panelProps }
}
DataTable複合コンポーネント
import { ref, computed, provide, inject, type Ref, type ComputedRef, type InjectionKey } from 'vue'
interface ColumnDef<T = any> {
key: string
label: string
sortable?: boolean
width?: string
align?: 'left' | 'center' | 'right'
render?: (value: any, row: T) => string
}
interface SortState {
key: string
direction: 'asc' | 'desc'
}
interface DataTableContext<T = any> {
columns: Ref<ColumnDef<T>[]>
data: Ref<T[]>
sortState: Ref<SortState | null>
selectedKeys: Ref<Set<string | number>>
rowKey: string
toggleSort: (key: string) => void
toggleRowSelect: (key: string | number) => void
toggleAllRows: () => void
isAllSelected: ComputedRef<boolean>
isIndeterminate: ComputedRef<boolean>
sortedData: ComputedRef<T[]>
}
const DATATABLE_KEY: InjectionKey<DataTableContext> = Symbol('datatable')
export function useDataTableProvider<T extends Record<string, any>>(options: {
columns: Ref<ColumnDef<T>[]>
data: Ref<T[]>
rowKey?: string
selectable?: boolean
onSelectionChange?: (keys: (string | number)[]) => void
onSortChange?: (state: SortState | null) => void
}) {
const {
columns,
data,
rowKey = 'id',
selectable = false,
onSelectionChange,
onSortChange
} = options
const sortState = ref<SortState | null>(null)
const selectedKeys = ref<Set<string | number>>(new Set())
const sortedData = computed(() => {
if (!sortState.value) return data.value
const { key, direction } = sortState.value
return [...data.value].sort((a, b) => {
const aVal = a[key]
const bVal = b[key]
const modifier = direction === 'asc' ? 1 : -1
if (aVal < bVal) return -1 * modifier
if (aVal > bVal) return 1 * modifier
return 0
})
})
const isAllSelected = computed(() =>
data.value.length > 0 && data.value.every((row) => selectedKeys.value.has(row[rowKey]))
)
const isIndeterminate = computed(() =>
!isAllSelected.value && data.value.some((row) => selectedKeys.value.has(row[rowKey]))
)
const toggleSort = (key: string): void => {
const current = sortState.value
if (current?.key === key) {
sortState.value = current.direction === 'asc'
? { key, direction: 'desc' }
: null
} else {
sortState.value = { key, direction: 'asc' }
}
onSortChange?.(sortState.value)
}
const toggleRowSelect = (key: string | number): void => {
const newKeys = new Set(selectedKeys.value)
if (newKeys.has(key)) {
newKeys.delete(key)
} else {
newKeys.add(key)
}
selectedKeys.value = newKeys
onSelectionChange?.(Array.from(newKeys))
}
const toggleAllRows = (): void => {
if (isAllSelected.value) {
selectedKeys.value = new Set()
} else {
selectedKeys.value = new Set(data.value.map((row) => row[rowKey]))
}
onSelectionChange?.(Array.from(selectedKeys.value))
}
const context: DataTableContext<T> = {
columns, data: sortedData, sortState, selectedKeys, rowKey,
toggleSort, toggleRowSelect, toggleAllRows, isAllSelected, isIndeterminate, sortedData
}
provide(DATATABLE_KEY, context)
return context
}
export function useDataTableConsumer(): DataTableContext {
const context = inject(DATATABLE_KEY)
if (!context) throw new Error('useDataTableConsumer must be used inside DataTable')
return context
}
使用例:
<script setup lang="ts">
import { ref } from 'vue'
import { useAccordionProvider, useAccordionItem } from '@/design-system/accordion'
import { useDataTableProvider, useDataTableConsumer } from '@/design-system/datatable'
const accordion = useAccordionProvider({
multiple: true,
initialActiveKeys: ['item-1'],
onChange: (keys) => console.log('Active:', keys)
})
const columns = ref([
{ key: 'name', label: '名前', sortable: true },
{ key: 'status', label: 'ステータス', sortable: true },
{ key: 'createdAt', label: '作成日', sortable: true }
])
const data = ref([
{ id: 1, name: 'プロジェクトA', status: 'アクティブ', createdAt: '2026-01-15' },
{ id: 2, name: 'プロジェクトB', status: 'アーカイブ', createdAt: '2026-03-20' }
])
const table = useDataTableProvider({
columns,
data,
selectable: true,
onSelectionChange: (keys) => console.log('選択:', keys)
})
</script>
<template>
<div class="space-y-4">
<div class="border rounded-lg divide-y">
<div v-for="item in ['item-1', 'item-2', 'item-3']" :key="item">
<div v-bind="useAccordionItem(item).headerProps()"
class="px-4 py-3 cursor-pointer hover:bg-gray-50 flex justify-between">
<span>アコーディオンパネル {{ item }}</span>
<span>{{ useAccordionItem(item).isOpen.value ? '−' : '+' }}</span>
</div>
<div v-bind="useAccordionItem(item).panelProps()" class="px-4 py-3">
コンテンツエリア {{ item }}
</div>
</div>
</div>
<table class="w-full border-collapse">
<thead>
<tr class="border-b">
<th v-for="col in columns" :key="col.key"
@click="col.sortable && table.toggleSort(col.key)"
:class="['px-4 py-2 text-left', col.sortable ? 'cursor-pointer' : '']">
{{ col.label }}
{{ table.sortState.value?.key === col.key ? (table.sortState.value.direction === 'asc' ? '↑' : '↓') : '' }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in table.sortedData.value" :key="row.id" class="border-b hover:bg-gray-50">
<td v-for="col in columns" :key="col.key" class="px-4 py-2">{{ row[col.key] }}</td>
</tr>
</tbody>
</table>
</div>
</template>
パターン4:多態コンポーネント(Polymorphic Components)
解決する問題:同じコンポーネントを異なるHTMLタグやカスタムコンポーネントとしてレンダリングする必要がある(例:Buttonを<button>、<a>、<router-link>として)。多態コンポーネントはasプロップスで動的レンダリングを実現します。
基本多態コンポーネント
import { defineComponent, type PropType, type ConcreteComponent } from 'vue'
type AsProp = string | ConcreteComponent
export const Box = defineComponent({
name: 'Box',
props: {
as: {
type: [String, Object] as PropType<AsProp>,
default: 'div'
}
},
setup(props, { slots }) {
return () => {
const Component = props.as
return (
<Component class="box">
{slots.default?.()}
</Component>
)
}
}
})
完全Button多態コンポーネント
import { defineComponent, type PropType, type ConcreteComponent, computed } from 'vue'
type AsProp = string | ConcreteComponent
type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'link'
type ButtonSize = 'xs' | 'sm' | 'md' | 'lg'
const variantClasses: Record<ButtonVariant, string> = {
solid: 'bg-[var(--color-primary)] text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)]',
outline: 'border border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary-light)]',
ghost: 'text-[var(--color-primary)] hover:bg-[var(--color-primary-light)]',
link: 'text-[var(--color-primary)] underline hover:text-[var(--color-primary-hover)]'
}
const sizeClasses: Record<ButtonSize, string> = {
xs: 'px-2 py-1 text-[var(--typography-text-xs)]',
sm: 'px-3 py-1.5 text-[var(--typography-text-sm)]',
md: 'px-4 py-2 text-[var(--typography-text-base)]',
lg: 'px-6 py-3 text-[var(--typography-text-lg)]'
}
export const Button = defineComponent({
name: 'DsButton',
props: {
as: {
type: [String, Object] as PropType<AsProp>,
default: 'button'
},
variant: {
type: String as PropType<ButtonVariant>,
default: 'solid'
},
size: {
type: String as PropType<ButtonSize>,
default: 'md'
},
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
block: {
type: Boolean,
default: false
}
},
setup(props, { slots }) {
const classes = computed(() => [
'inline-flex items-center justify-center font-medium rounded-[var(--radius-md)]',
'transition-[var(--transition-normal)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-2',
variantClasses[props.variant],
sizeClasses[props.size],
props.block ? 'w-full' : '',
props.disabled || props.loading ? 'opacity-50 cursor-not-allowed pointer-events-none' : '',
props.loading ? 'cursor-wait' : ''
])
return () => {
const Component = props.as
const componentProps = typeof props.as === 'string' && props.as === 'button'
? { disabled: props.disabled || props.loading }
: {}
return (
<Component
class={classes.value}
role="button"
aria-disabled={props.disabled || props.loading}
aria-busy={props.loading}
{...componentProps}
>
{props.loading && (
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{slots.default?.()}
</Component>
)
}
}
})
汎用多態コンポーネントユーティリティ型
import type { HTMLAttributes, AnchorHTMLAttributes } from 'vue'
type ElementMap = {
div: HTMLAttributes
button: HTMLAttributes
a: AnchorHTMLAttributes
span: HTMLAttributes
p: HTMLAttributes
h1: HTMLAttributes
h2: HTMLAttributes
h3: HTMLAttributes
section: HTMLAttributes
article: HTMLAttributes
nav: HTMLAttributes
main: HTMLAttributes
header: HTMLAttributes
footer: HTMLAttributes
}
type PolymorphicProps<T extends keyof ElementMap> = {
as?: T | ConcreteComponent
} & ElementMap[T]
export function createPolymorphicComponent<T extends keyof ElementMap>(defaultTag: T) {
return defineComponent({
props: {
as: {
type: [String, Object] as PropType<T | ConcreteComponent>,
default: defaultTag
}
},
setup(props, { slots }) {
return () => {
const Component = props.as as string | ConcreteComponent
return <Component>{slots.default?.()}</Component>
}
}
})
}
export const Text = createPolymorphicComponent('p')
export const Heading = createPolymorphicComponent('h2')
export const Section = createPolymorphicComponent('section')
使用例:
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { Button } from '@/design-system/Button'
import { Text } from '@/design-system/Text'
import { Heading } from '@/design-system/Heading'
</script>
<template>
<Button variant="solid" size="md">通常ボタン</Button>
<Button as="a" variant="outline" href="https://toolsku.com" target="_blank">リンクボタン</Button>
<Button :as="RouterLink" variant="ghost" to="/about">ルーターボタン</Button>
<Button variant="solid" :loading="true">読み込み中</Button>
<Button variant="link" size="sm" :disabled="true">無効リンク</Button>
<Text as="span" class="text-gray-500">インラインテキスト</Text>
<Heading as="h1" class="text-3xl font-bold">ページタイトル</Heading>
<Text as="label" class="block mb-1">フォームラベル</Text>
</template>
パターン5:コンポーネントライブラリ配布とTree-shaking
解決する問題:コンポーネントライブラリの開発完了後、パッケージ化・配布、使用コンポーネントのみのインポート保証、優れた開発者体験の提供方法。
Vite Libraryモード設定
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue(),
dts({
insertTypesEntry: true,
include: ['src/**/*.ts', 'src/**/*.vue'],
outDir: 'dist/types'
})
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'ToolsKuDesign',
formats: ['es', 'cjs'],
fileName: (format) => `toolsku-design.${format === 'es' ? 'mjs' : 'cjs'}`
},
rollupOptions: {
external: ['vue', 'vue-router', '@vueuse/core'],
output: {
globals: {
vue: 'Vue',
'vue-router': 'VueRouter',
'@vueuse/core': 'VueUse'
},
preserveModules: true,
preserveModulesRoot: 'src'
}
},
cssCodeSplit: true,
sourcemap: true,
minify: 'esbuild'
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})
コンポーネント登録とTree-shaking
import type { App, Plugin } from 'vue'
export { useDialog } from './composables/useDialog'
export { useSelect } from './composables/useSelect'
export { useTabs } from './composables/useTabs'
export { useTheme } from './composables/useTheme'
export { useAccordionProvider, useAccordionItem } from './composables/useAccordion'
export { useDataTableProvider, useDataTableConsumer } from './composables/useDataTable'
export { Button } from './components/Button.vue'
export { Box } from './components/Box.vue'
export { Text } from './components/Text.vue'
export { Heading } from './components/Heading.vue'
import { Button } from './components/Button.vue'
import { Box } from './components/Box.vue'
import { Text } from './components/Text.vue'
import { Heading } from './components/Heading.vue'
const components = { Button, Box, Text, Heading } as const
type ComponentName = keyof typeof components
export const ToolsKuDesign: Plugin = {
install(app: App) {
for (const [name, component] of Object.entries(components)) {
app.component(`Ds${name}`, component)
}
}
}
export function createInstaller(selectComponents?: ComponentName[]): Plugin {
return {
install(app: App) {
if (!selectComponents) {
for (const [name, component] of Object.entries(components)) {
app.component(`Ds${name}`, component)
}
return
}
for (const name of selectComponents) {
const component = components[name]
if (component) {
app.component(`Ds${name}`, component)
}
}
}
}
}
package.json設定
{
"name": "@toolsku/design-system",
"version": "1.0.0",
"type": "module",
"files": [
"dist",
"README.md"
],
"main": "./dist/toolsku-design.cjs",
"module": "./dist/toolsku-design.mjs",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/toolsku-design.mjs",
"require": "./dist/toolsku-design.cjs"
},
"./composables/*": {
"types": "./dist/types/composables/*.d.ts",
"import": "./dist/composables/*.mjs"
},
"./components/*": {
"types": "./dist/types/components/*.d.ts",
"import": "./dist/components/*.mjs"
},
"./style.css": "./dist/style.css"
},
"sideEffects": [
"**/*.css"
],
"peerDependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"vue": "^3.5.0",
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^6.0.0",
"vite-plugin-dts": "^4.0.0",
"typescript": "^5.6.0"
}
}
コンシューマーの使用方法
// 方法1:フル登録
import { ToolsKuDesign } from '@toolsku/design-system'
import '@toolsku/design-system/style.css'
app.use(ToolsKuDesign)
// 方法2:選択的登録
import { createInstaller } from '@toolsku/design-system'
app.use(createInstaller(['Button', 'Text']))
// 方法3:直接インポート(Tree-shakingフレンドリー)
import { Button } from '@toolsku/design-system'
import { useDialog } from '@toolsku/design-system/composables/useDialog'
// 方法4:Composableのみ(ゼロUI依存)
import { useTheme } from '@toolsku/design-system/composables/useTheme'
5つのデザインシステムアンチパターン
アンチパターン1:コンポーネント内にToken値をハードコード
// ❌ 悪い例:ハードコードされた色値
const styles = { color: '#3b82f6', padding: '16px' }
// ✅ 良い例:Design Tokenを使用
const styles = { color: 'var(--color-primary)', padding: 'var(--spacing-4)' }
アンチパターン2:Composable内で直接DOM操作
// ❌ 悪い例:Composableが直接DOMを操作
export function useBadDialog() {
const open = () => document.getElementById('dialog')!.style.display = 'block'
return { open }
}
// ✅ 良い例:リアクティブ状態でUIを駆動
export function useGoodDialog() {
const isOpen = ref(false)
const open = () => { isOpen.value = true }
return { isOpen, open }
}
アンチパターン3:型安全でないKeyでprovide/inject
// ❌ 悪い例:文字列キー、型安全性なし
provide('accordion', context)
const ctx = inject('accordion') // any型
// ✅ 良い例:InjectionKeyを使用
const ACCORDION_KEY: InjectionKey<AccordionContext> = Symbol('accordion')
provide(ACCORDION_KEY, context)
const ctx = inject(ACCORDION_KEY)! // 完全な型推論
アンチパターン4:フルバンドルパッケージング
// ❌ 悪い例:単一ファイル出力、Tree-shaking不可
rollupOptions: {
output: { entryFileNames: 'bundle.js' }
}
// ✅ 良い例:preserveModulesでモジュール構造を保持
rollupOptions: {
output: { preserveModules: true, preserveModulesRoot: 'src' }
}
アンチパターン5:多態コンポーネントでセマンティックHTMLを失う
// ❌ 悪い例:すべてがdiv
<div @click="navigate" class="cursor-pointer">私たちについて</div>
// ✅ 良い例:asプロップスでセマンティクスを保持
<Button as="a" href="/about">私たちについて</Button>
デザインシステムパフォーマンス最適化チェックリスト
| 最適化項目 | 実装方法 | 効果 |
|---|---|---|
| CSS変数でランタイムJSを代替 | var(--token) で computed style を代替 |
JS実行削減 |
| preserveModules | Vite rollup設定 | 精密なTree-shaking |
| shallowRefでrefを代替 | 大きなオブジェクト/配列にshallowRef | 深いリアクティビティコスト削減 |
| 遅延コンポーネント読み込み | defineAsyncComponent |
初回ロード削減 |
| CSS Code Split | cssCodeSplit: true |
オンデマンドスタイル読み込み |
| 仮想スクロール | 大きなリストで仮想スクロール | DOMノード削減 |
| Tokenグループ読み込み | テーマグループごとにCSS変数を注入 | 初期CSSサイズ削減 |
10の一般的な問題トラブルシューティング
inject() can only be used inside setup():複合コンポーネントのサブコンポーネントComposableはsetup()内で呼び出す必要があり、非同期コールバックでは使用不可- テーマ切り替えのちらつき:
<html>タグにインラインスクリプトを追加してdata-themeを事前設定し、FOUCを回避 - CSS変数が動作しない:変数名に大文字が含まれていないか確認(CSS変数は大文字小文字を区別しないが小文字推奨)、スコープセレクタを確認
- Tree-shakingが動作しない:
package.jsonのsideEffects設定が正しいか確認、CSSファイルは副作用としてマーク必要 - 多態コンポーネントのTypeScriptエラー:
PropTypeでasプロップス型を明示的に宣言、Stringコンストラクタのstring推論を回避 - provide/injectのリアクティビティ消失:
ref/reactiveオブジェクト自体をprovideし、.valueではないことを確認 - コンポーネントライブラリのスタイル汚染:CSS ModulesやBEM命名プレフィックスでグローバルスタイル競合を回避
- ヘッドレスコンポーネントのSSRエラー:
onMounted/onUnmountedはSSRで実行されない、DOM操作はonMountedに配置 - ビルド後の型ファイル欠落:
vite-plugin-dts設定とtsconfig.jsonのdeclaration: trueを確認 - コンポーネント登録後のテンプレート認識不可:コンポーネントの
nameオプションを確認、または<script setup>で自動推論
Vue3デザインシステム vs Reactデザインシステム比較
| 比較次元 | Vue3デザインシステム | Reactデザインシステム |
|---|---|---|
| ロジック再利用 | Composable + provide/inject | Custom Hooks + Context |
| テーマシステム | CSS変数 + useTheme | CSS-in-JS / CSS変数 |
| 複合コンポーネント | provide/inject | Context API |
| 多態コンポーネント | asプロップス + defineComponent |
asプロップス + forwardRef |
| スタイリング | Scoped CSS / CSS Modules | Styled-components / Tailwind |
| 型安全性 | PropType + ジェネリクス | TypeScriptジェネリクス |
| 状態管理 | ref/reactive | useState/useReducer |
| ビルドツール | Vite Library Mode | Rollup / tsup |
| SSRサポート | Nuxt3内蔵 | Next.js内蔵 |
核心的な違い:Vue3のリアクティビティシステムにより、Design Tokenとコンポーネントのバインディングがより自然になります。CSS変数 + refの組み合わせはReactのCSS-in-JSソリューションより高性能です。
推奨ツール
デザインシステム構築時に生産性を向上させるオンラインツール:
- JSONフォーマッター — Design Token JSON設定をフォーマット、Token構造を検証
- コードフォーマッター — TypeScript/Vueコンポーネントコードをフォーマット、コードスタイルを統一
- Base64エンコーダー — SVGアイコンをData URIとしてエンコード、コンポーネントライブラリに埋め込み
まとめ:Vue3デザインシステムの5つのプロダクションパターンは、完全なコンポーネントライブラリアーキテクチャを形成します — ヘッドレスコンポーネント(useDialog/useSelect/useTabs)はロジックとUIを分離し、Design Token(CSSカスタムプロパティ + useTheme)はビジュアルとコンポーネントを分離し、複合コンポーネント(Accordion/DataTable)は合成と実装を分離し、多態コンポーネント(asプロップス)はセマンティクスとレンダリングを分離し、コンポーネントライブラリ配布(Vite Library Mode + Tree-shaking)は開発と消費を分離します。優れたデザインシステムは創造性を制限するものではなく、創造性を本当に重要な場所に集中させるものです。
ブラウザローカルツールを無料で試す →