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
inject() can only be used inside setup(): Compound component sub-component Composables must be called insetup(), not in async callbacks- Theme switch flicker: Add inline script on
<html>to pre-setdata-theme, avoiding FOUC - CSS variables not working: Check variable names for uppercase (CSS variables are case-insensitive but lowercase recommended), verify scope selectors
- Tree-shaking not working: Ensure
sideEffectsinpackage.jsonis configured correctly, CSS files must be marked as side effects - Polymorphic component TypeScript errors: Use
PropTypeto explicitly declareasprop type, avoidStringconstructor inferring asstring - provide/inject reactivity loss: Ensure you provide the
ref/reactiveobject itself, not.value - Component library style pollution: Use CSS Modules or BEM naming prefixes to avoid global style conflicts
- Headless component SSR errors:
onMounted/onUnmounteddon't execute in SSR, DOM operations must be inonMounted - Missing type files after build: Verify
vite-plugin-dtsconfig anddeclaration: trueintsconfig.json - Component not recognized after registration: Check component
nameoption 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.
Recommended Tools
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 →