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:

code
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:

typescript
// ❌ 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:

  1. Keep props count < 8 — if you need more, you're probably missing a abstraction
  2. Use CSS variables for styling — don't expose every style property
  3. Provide sensible defaults — most components should work with zero props
  4. Document the "why" — every prop should have a reason

3. Design Tokens

Tokens are the single source of truth:

typescript
// 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

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

vue
<!-- 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:

typescript
// 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:

bash
# 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:

json
{
  "dependencies": {
    "@acme/design-system": "^1.2.0"
  }
}

Adoption Strategy

Getting teams to use the system was as important as building it:

  1. Dogfood internally — our flagship product used v1.0 before public release
  2. Create migration guides — "How to replace your Button with DesignSystemButton"
  3. Celebrate wins — "we deleted three duplicate Button implementations this sprint" beats any adoption mandate
  4. Make it easier to use than not — publish to npm, show install in docs
  5. 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

  1. Tokens are the foundation — invest heavily in design tokens before components
  2. API design is everything — a good API is adopted naturally; a bad one requires enforcement
  3. Storybook isn't optional — it's how designers and developers communicate
  4. Version properly — teams need confidence that upgrades won't break things
  5. One person owns it — design systems need a maintainer, not a committee
  6. 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.