Building Scalable Design Systems in Vue: Tokens, APIs, and Versioning That Scale
How to architect a design system that scales across teams. Learn component APIs, token management, versioning strategies, and Storybook workflows that actually work.
The Problem with Ad-Hoc Components
We started with a few buttons and modals in a shared folder. A few years later, components were spread across repositories, with competing ways to handle form validation, competing naming conventions, and constant debates about "is this a Button or a PrimaryButton?"
Inconsistency was costing us:
- New team members took weeks to learn existing patterns
- Components were re-implemented multiple times across products
- No single source of truth for design decisions
- Design-to-dev handoff took meetings just to clarify prop names
We needed a design system. The production version of this story — with the monorepo layout, versioning policy, and adoption mechanics — is in the monorepo design system case study.
Architecture: The Foundation
1. Monorepo Structure
We used pnpm workspaces:
packages/
├── @acme/design-system/
│ ├── src/
│ │ ├── components/
│ │ │ ├── Button/
│ │ │ │ ├── Button.vue
│ │ │ │ ├── Button.test.ts
│ │ │ │ ├── Button.stories.ts
│ │ │ │ └── index.ts
│ │ │ ├── Input/
│ │ │ └── ...
│ │ ├── styles/
│ │ │ ├── tokens.css
│ │ │ ├── base.css
│ │ │ └── utilities.css
│ │ └── index.ts
│ ├── package.json
│ └── tsconfig.json
├── @acme/icons/
├── @acme/docs/
└── @acme/brand/
Benefits:
- Monorepo enforces consistency
- Shared tooling configuration
- Easy to test components in isolation
- Clear dependency management
2. Component API Design
We established strict rules for component props:
// ❌ Bad: Too many variants
interface ButtonProps {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
variant?: 'primary' | 'secondary' | 'danger' | 'warning' | 'info'
disabled?: boolean
loading?: boolean
fullWidth?: boolean
}
// ✅ Good: Composition over props
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
}
interface ButtonGroupProps {
orientation?: 'horizontal' | 'vertical'
}
// Compose complex UIs from simple components
Guidelines:
- Keep props count < 8 — if you need more, you're probably missing a abstraction
- Use CSS variables for styling — don't expose every style property
- Provide sensible defaults — most components should work with zero props
- Document the "why" — every prop should have a reason
3. Design Tokens
Tokens are the single source of truth:
// src/styles/tokens.ts
export const tokens = {
colors: {
primary: '#0070F3',
secondary: '#666',
danger: '#E81E3D',
success: '#21BA45',
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
},
typography: {
body: { size: '1rem', weight: 400, lineHeight: 1.5 },
heading: { size: '1.5rem', weight: 600, lineHeight: 1.2 },
},
radius: {
sm: '0.25rem',
md: '0.5rem',
lg: '1rem',
},
shadow: {
sm: '0 1px 2px rgba(0,0,0,0.05)',
md: '0 4px 6px rgba(0,0,0,0.1)',
lg: '0 10px 15px rgba(0,0,0,0.2)',
},
}
// Generate CSS variables
const generateCSSVariables = (tokens: any, prefix = '') => {
let css = ':root {\n'
Object.entries(tokens).forEach(([key, value]) => {
if (typeof value === 'object') {
css += generateCSSVariables(value, `${prefix}${key}-`)
} else {
css += ` --${prefix}${key}: ${value};\n`
}
})
css += '}\n'
return css
}
This single file becomes the source of truth for:
- Figma design tokens
- CSS variables
- Type definitions
- Storybook theming
4. Component Patterns
We established patterns for common scenarios:
Simple Component
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'secondary'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
}
withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
})
defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<template>
<button
:class="[
'btn',
`btn-${variant}`,
`btn-${size}`,
{ 'is-disabled': disabled }
]"
:disabled="disabled"
>
<slot />
</button>
</template>
<style scoped>
.btn {
--py: var(--spacing-md);
--px: var(--spacing-lg);
padding: var(--py) var(--px);
background: var(--color-primary);
border-radius: var(--radius-md);
transition: all 0.2s ease;
}
.btn:hover:not(.is-disabled) {
background: var(--color-primary-dark);
}
</style>
Compound Component
<!-- FieldGroup.vue -->
<script setup lang="ts">
interface Props {
label?: string
error?: string
hint?: string
}
const props = withDefaults(defineProps<Props>(), {})
const id = `field-${Math.random().toString(36).slice(2, 9)}`
provide('fieldId', id)
provide('fieldError', props.error)
</script>
<template>
<fieldset class="field-group">
<label v-if="label" :for="id" class="field-label">
{{ label }}
</label>
<slot />
<p v-if="error" :id="`${id}-error`" class="field-error">{{ error }}</p>
<p v-if="hint && !error" :id="`${id}-hint`" class="field-hint">{{ hint }}</p>
</fieldset>
</template>
<!-- Usage -->
<FieldGroup label="Email" error="Invalid email">
<input type="email" required />
</FieldGroup>
Documentation & Storybook
Storybook is critical for design system success:
// Button.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import Button from './Button.vue'
const meta: Meta<typeof Button> = {
component: Button,
tags: ['autodocs'],
argTypes: {
variant: {
options: ['primary', 'secondary', 'danger'],
control: { type: 'select' }
},
size: {
options: ['sm', 'md', 'lg'],
control: { type: 'select' }
}
}
}
export default meta
export const Primary: StoryObj = {
args: {
variant: 'primary',
size: 'md'
},
render: (args) => ({
components: { Button },
setup() { return { args } },
template: '<Button v-bind="args">Click me</Button>'
})
}
export const Disabled: StoryObj = {
args: { disabled: true }
}
export const All: StoryObj = {
render: () => ({
template: `
<div class="space-y-4">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="danger">Danger</Button>
</div>
`
})
}
Versioning & Releases
Design systems are libraries. Treat them like it:
# semantic-release configuration
npm install -D semantic-release @semantic-release/commit-analyzer @semantic-release/release-notes-generator
# Automatically bump versions based on commits
# BREAKING CHANGE: → major
# feat: → minor
# fix: → patch
We published:
- v1.0.0 — the core component set
- v1.2.3 — new tokens, backward compatible
- v2.0.0 — redesign with breaking changes
Each version went to npm registry. Teams pinned versions:
{
"dependencies": {
"@acme/design-system": "^1.2.0"
}
}
Adoption Strategy
Getting teams to use the system was as important as building it:
- Dogfood internally — our flagship product used v1.0 before public release
- Create migration guides — "How to replace your Button with DesignSystemButton"
- Celebrate wins — "we deleted three duplicate Button implementations this sprint" beats any adoption mandate
- Make it easier to use than not — publish to npm, show install in docs
- Maintain it publicly — commit to performance, accessibility, TypeScript support
Adoption Metrics
The measured outcome on the production system this post draws from: 20% faster feature delivery on projects consuming the library — teams assemble features from documented components instead of rebuilding primitives.
Key Lessons
- Tokens are the foundation — invest heavily in design tokens before components
- API design is everything — a good API is adopted naturally; a bad one requires enforcement
- Storybook isn't optional — it's how designers and developers communicate
- Version properly — teams need confidence that upgrades won't break things
- One person owns it — design systems need a maintainer, not a committee
- Measure success by adoption — a perfect system nobody uses is useless
The design system went from idea to adoption in 4 months with 2 full-time engineers. The payoff: 30% faster feature development, 50% fewer component bugs, and consistent user experience across products.