Vue3 Design System: 5 Production Patterns from Headless Components to Design Tokens

前端工程

Why You Need a Vue3 Design System in 2026

In 2026, frontend engineering has entered deep waters. When your team grows from 3 to 30, when your projects expand from 1 to 10, a team without a design system is doomed to struggle with style fragmentation and component reinvention. Vue3's Composition API naturally fits design system architecture — Composables provide the logic layer, Design Tokens provide the visual layer, and components provide the presentation layer. These three layers compose orthogonally.

Dimension Without Design System With Design System
Button styles 17 different buttons 1 unified button
Theme switching Hardcoded per component CSS variable one-click switch
Component reuse Copy-paste code npm install
Design consistency Designers argue daily Token-driven alignment
Bundle size Repeated code bloat Tree-shaking optimized
Onboarding cost Varies per person Unified API conventions

Core thesis: A design system is not a UI framework — it's design decisions codified. Headless components decouple logic from UI, Design Tokens decouple visuals from components, and compound components decouple composition from implementation.


Pattern 1: Headless Components with Composables

Problem solved: Component logic is deeply coupled with UI presentation — changing the UI means rewriting the logic. Headless components extract state management, keyboard interactions, and accessibility logic into Composables, leaving the UI layer entirely under consumer control.

useDialog — Dialog Logic

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 — Select Logic

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 — Tabs Logic

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
  }
}

Usage — Fully Custom 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: 'Overview' },
  { key: 'api', label: 'API' },
  { key: 'examples', label: 'Examples' }
])
const { activeKey, tabListProps, getTabProps, getPanelProps } = useTabs({ tabs })
</script>

<template>
  <button v-bind="triggerProps()">Open Dialog</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>Custom Dialog UI</h2>
      <button @click="close">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 }} content area</p>
  </div>
</template>

Pattern 2: Design Tokens with CSS Custom Properties

Problem solved: Colors, spacing, border-radius and other visual properties are scattered across components. Theme switching requires modifying each one individually. Design Tokens centralize visual decisions and enable runtime theme switching via CSS custom properties.

Token System Architecture

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'
  }
}

Theme Management 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 }
}

Using Design Tokens with Tailwind CSS

@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

Pattern 3: Compound Components

Problem solved: Components like Accordion and DataTable consist of multiple related sub-components that share state but shouldn't pass props through every level. Compound components use provide/inject for implicit state sharing.

Accordion Compound Component

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 Compound Component

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
}

Usage Example:

<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: 'Name', sortable: true },
  { key: 'status', label: 'Status', sortable: true },
  { key: 'createdAt', label: 'Created', sortable: true }
])
const data = ref([
  { id: 1, name: 'Project A', status: 'Active', createdAt: '2026-01-15' },
  { id: 2, name: 'Project B', status: 'Archived', createdAt: '2026-03-20' }
])

const table = useDataTableProvider({
  columns,
  data,
  selectable: true,
  onSelectionChange: (keys) => console.log('Selected:', 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>Accordion Panel {{ item }}</span>
          <span>{{ useAccordionItem(item).isOpen.value ? '−' : '+' }}</span>
        </div>
        <div v-bind="useAccordionItem(item).panelProps()" class="px-4 py-3">
          Content area {{ 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>

Pattern 4: Polymorphic Components

Problem solved: The same component needs to render as different HTML tags or custom components (e.g., Button sometimes renders as <button>, sometimes as <a> or <router-link>). Polymorphic components use the as prop for dynamic rendering.

Basic Polymorphic Component

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>
      )
    }
  }
})

Full Button Polymorphic Component

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>
      )
    }
  }
})

Generic Polymorphic Component Utility Types

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')

Usage Example:

<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">Regular Button</Button>
  <Button as="a" variant="outline" href="https://toolsku.com" target="_blank">Link Button</Button>
  <Button :as="RouterLink" variant="ghost" to="/about">Router Button</Button>
  <Button variant="solid" :loading="true">Loading</Button>
  <Button variant="link" size="sm" :disabled="true">Disabled Link</Button>

  <Text as="span" class="text-gray-500">Inline text</Text>
  <Heading as="h1" class="text-3xl font-bold">Page Title</Heading>
  <Text as="label" class="block mb-1">Form Label</Text>
</template>

Pattern 5: Component Library Distribution and Tree-shaking

Problem solved: After developing a component library, how to package and distribute it, ensure consumers only import used components, and provide a good developer experience.

Vite Library Mode Configuration

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')
    }
  }
})

Component Registration and 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 Configuration

{
  "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"
  }
}

Consumer Usage

// Method 1: Full registration
import { ToolsKuDesign } from '@toolsku/design-system'
import '@toolsku/design-system/style.css'
app.use(ToolsKuDesign)

// Method 2: Selective registration
import { createInstaller } from '@toolsku/design-system'
app.use(createInstaller(['Button', 'Text']))

// Method 3: Direct import (Tree-shaking friendly)
import { Button } from '@toolsku/design-system'
import { useDialog } from '@toolsku/design-system/composables/useDialog'

// Method 4: Composable only (zero UI dependency)
import { useTheme } from '@toolsku/design-system/composables/useTheme'

5 Design System Anti-Patterns

Anti-Pattern 1: Hardcoded Token Values in Components

// ❌ Bad: Hardcoded color values
const styles = { color: '#3b82f6', padding: '16px' }

// ✅ Good: Use Design Tokens
const styles = { color: 'var(--color-primary)', padding: 'var(--spacing-4)' }

Anti-Pattern 2: Direct DOM Manipulation in Composables

// ❌ Bad: Composable directly manipulates DOM
export function useBadDialog() {
  const open = () => document.getElementById('dialog')!.style.display = 'block'
  return { open }
}

// ✅ Good: Drive UI through reactive state
export function useGoodDialog() {
  const isOpen = ref(false)
  const open = () => { isOpen.value = true }
  return { isOpen, open }
}

Anti-Pattern 3: provide/inject Without Type-Safe Keys

// ❌ Bad: String key, no type safety
provide('accordion', context)
const ctx = inject('accordion') // any type

// ✅ Good: Use InjectionKey
const ACCORDION_KEY: InjectionKey<AccordionContext> = Symbol('accordion')
provide(ACCORDION_KEY, context)
const ctx = inject(ACCORDION_KEY)! // Full type inference

Anti-Pattern 4: Full Bundle Packaging

// ❌ Bad: Single file output, no Tree-shaking
rollupOptions: {
  output: { entryFileNames: 'bundle.js' }
}

// ✅ Good: preserveModules keeps module structure
rollupOptions: {
  output: { preserveModules: true, preserveModulesRoot: 'src' }
}

Anti-Pattern 5: Polymorphic Components Losing Semantic HTML

// ❌ Bad: Everything is a div
<div @click="navigate" class="cursor-pointer">About Us</div>

// ✅ Good: Use as prop to preserve semantics
<Button as="a" href="/about">About Us</Button>

Design System Performance Optimization Checklist

Optimization Implementation Effect
CSS variables over runtime JS var(--token) instead of computed style Reduced JS execution
preserveModules Vite rollup config Precise Tree-shaking
shallowRef over ref Large objects/arrays use shallowRef Reduced deep reactivity cost
Lazy component loading defineAsyncComponent First-load reduction
CSS Code Split cssCodeSplit: true On-demand style loading
Virtual scrolling Large lists use virtual scroll Fewer DOM nodes
Token group loading Inject CSS variables by theme group Smaller initial CSS

10 Common Issue Troubleshooting

  1. inject() can only be used inside setup(): Compound component sub-component Composables must be called in setup(), not in async callbacks
  2. Theme switch flicker: Add inline script on <html> to pre-set data-theme, avoiding FOUC
  3. CSS variables not working: Check variable names for uppercase (CSS variables are case-insensitive but lowercase recommended), verify scope selectors
  4. Tree-shaking not working: Ensure sideEffects in package.json is configured correctly, CSS files must be marked as side effects
  5. Polymorphic component TypeScript errors: Use PropType to explicitly declare as prop type, avoid String constructor inferring as string
  6. provide/inject reactivity loss: Ensure you provide the ref/reactive object itself, not .value
  7. Component library style pollution: Use CSS Modules or BEM naming prefixes to avoid global style conflicts
  8. Headless component SSR errors: onMounted/onUnmounted don't execute in SSR, DOM operations must be in onMounted
  9. Missing type files after build: Verify vite-plugin-dts config and declaration: true in tsconfig.json
  10. Component not recognized after registration: Check component name option or use <script setup> for auto-inference

Vue3 Design System vs React Design System

Dimension Vue3 Design System React Design System
Logic reuse Composable + provide/inject Custom Hooks + Context
Theme system CSS variables + useTheme CSS-in-JS / CSS variables
Compound components provide/inject Context API
Polymorphic components as prop + defineComponent as prop + forwardRef
Styling approach Scoped CSS / CSS Modules Styled-components / Tailwind
Type safety PropType + generics TypeScript generics
State management ref/reactive useState/useReducer
Build tooling Vite Library Mode Rollup / tsup
SSR support Nuxt3 built-in Next.js built-in

Core difference: Vue3's reactivity system makes Design Token-to-component binding more natural. The CSS variable + ref combination outperforms React's CSS-in-JS solutions.


When building design systems, these online tools boost productivity:

  • JSON Formatter — Format Design Token JSON configurations, validate Token structure
  • Code Formatter — Format TypeScript/Vue component code, unify code style
  • Base64 Encoder — Encode SVG icons as Data URIs for embedding in component libraries

Conclusion: The 5 production patterns of Vue3 design systems form a complete component library architecture — Headless components (useDialog/useSelect/useTabs) decouple logic from UI, Design Tokens (CSS custom properties + useTheme) decouple visuals from components, Compound components (Accordion/DataTable) decouple composition from implementation, Polymorphic components (as prop) decouple semantics from rendering, and Component library distribution (Vite Library Mode + Tree-shaking) decouple development from consumption. A good design system doesn't restrict creativity — it focuses creativity on what truly matters.

Try these browser-local tools — no sign-up required →

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