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の一般的な問題トラブルシューティング

  1. inject() can only be used inside setup():複合コンポーネントのサブコンポーネントComposableはsetup()内で呼び出す必要があり、非同期コールバックでは使用不可
  2. テーマ切り替えのちらつき<html>タグにインラインスクリプトを追加してdata-themeを事前設定し、FOUCを回避
  3. CSS変数が動作しない:変数名に大文字が含まれていないか確認(CSS変数は大文字小文字を区別しないが小文字推奨)、スコープセレクタを確認
  4. Tree-shakingが動作しないpackage.jsonsideEffects設定が正しいか確認、CSSファイルは副作用としてマーク必要
  5. 多態コンポーネントのTypeScriptエラーPropTypeasプロップス型を明示的に宣言、Stringコンストラクタのstring推論を回避
  6. provide/injectのリアクティビティ消失ref/reactiveオブジェクト自体をprovideし、.valueではないことを確認
  7. コンポーネントライブラリのスタイル汚染:CSS ModulesやBEM命名プレフィックスでグローバルスタイル競合を回避
  8. ヘッドレスコンポーネントのSSRエラーonMounted/onUnmountedはSSRで実行されない、DOM操作はonMountedに配置
  9. ビルド後の型ファイル欠落vite-plugin-dts設定とtsconfig.jsondeclaration: trueを確認
  10. コンポーネント登録後のテンプレート認識不可:コンポーネントの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ソリューションより高性能です。


推奨ツール

デザインシステム構築時に生産性を向上させるオンラインツール:


まとめ:Vue3デザインシステムの5つのプロダクションパターンは、完全なコンポーネントライブラリアーキテクチャを形成します — ヘッドレスコンポーネント(useDialog/useSelect/useTabs)はロジックとUIを分離し、Design Token(CSSカスタムプロパティ + useTheme)はビジュアルとコンポーネントを分離し、複合コンポーネント(Accordion/DataTable)は合成と実装を分離し、多態コンポーネント(asプロップス)はセマンティクスとレンダリングを分離し、コンポーネントライブラリ配布(Vite Library Mode + Tree-shaking)は開発と消費を分離します。優れたデザインシステムは創造性を制限するものではなく、創造性を本当に重要な場所に集中させるものです。

ブラウザローカルツールを無料で試す →

#Vue3设计系统#组件库#Headless组件#Design Token#Vue3#2026#前端工程