Building a design system in Vue: lessons from production
Most teams start with scattered component files, duplicate styles, and a color palette that drifts across their product suite. After shipping design systems across multiple production environments—from e-commerce platforms to internal admin dashboards—we've learned that building a design system in Vue requires discipline around tokens, component contracts, and distribution. This article walks through what actually works when you're managing UI consistency across several applications.
Why design systems matter in production
A design system is not a Figma file or a component library. It's a shared language for how your team builds interfaces. In production, consistency reduces cognitive load for users, cuts down rework when brand guidelines shift, and makes onboarding new developers faster. We've seen teams waste months recreating the same button, input, or card across three separate codebases. A well-structured design system prevents that.
The best time to start is early—ideally when you're still small enough to enforce standards but experienced enough to know what patterns actually repeat. Day 98 of serious Vue work is roughly where most teams realize they need one.
Tokens as CSS variables
Start with design tokens: the atomic units of your design—colors, spacing, typography, shadows, border radii. Store these as CSS variables, not hardcoded values in component styles. This gives you two critical powers: single-source-of-truth updates and runtime theme switching.
Here's how we structure token files:
// tokens/colors.ts
export const colorTokens = {
primary: '#0066CC',
primaryHover: '#0052A3',
primaryActive: '#003D7A',
neutral: {
50: '#FAFAFA',
100: '#F5F5F5',
200: '#EEEEEE',
300: '#E0E0E0',
500: '#9E9E9E',
900: '#212121',
},
semantic: {
success: '#10B981',
warning: '#F59E0B',
error: '#EF4444',
info: '#3B82F6',
},
};
export const spacingTokens = {
xs: '0.25rem', // 4px
sm: '0.5rem', // 8px
md: '1rem', // 16px
lg: '1.5rem', // 24px
xl: '2rem', // 32px
'2xl': '3rem', // 48px
};
export const typographyTokens = {
heading1: {
fontSize: '2rem',
fontWeight: 700,
lineHeight: 1.2,
},
heading2: {
fontSize: '1.5rem',
fontWeight: 600,
lineHeight: 1.3,
},
body: {
fontSize: '1rem',
fontWeight: 400,
lineHeight: 1.5,
},
};Convert these into a CSS file that gets imported globally:
// tokens/index.css
:root {
/* Colors */
--color-primary: #0066CC;
--color-primary-hover: #0052A3;
--color-primary-active: #003D7A;
--color-neutral-50: #FAFAFA;
--color-neutral-100: #F5F5F5;
--color-neutral-900: #212121;
--color-success: #10B981;
--color-error: #EF4444;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
/* Typography */
--font-heading-1: 700 2rem / 1.2 sans-serif;
--font-body: 400 1rem / 1.5 sans-serif;
}Components then reference these variables instead of hardcoding values:
// components/Button.vue
<template>
<button
:class="['btn', `btn--${variant}`, { 'btn--disabled': disabled }]"
@click="$emit('click')"
>
<slot />
</button>
</template>
<style scoped>
.btn {
padding: var(--space-sm) var(--space-md);
background-color: var(--color-primary);
color: white;
border: none;
border-radius: 0.375rem;
font: var(--font-body);
cursor: pointer;
transition: background-color 0.2s;
}
.btn:hover:not(.btn--disabled) {
background-color: var(--color-primary-hover);
}
.btn.btn--disabled {
background-color: var(--color-neutral-300);
cursor: not-allowed;
}
</style>The payoff: when marketing says "our blue needs to shift three shades darker," you update one CSS variable, and it cascades across your entire application. No grep, no find-and-replace across codebases.
Base components with clear props
Design systems live or die by their component API. If your button component has 27 optional props and accepts any HTML attribute imaginable, you've built a footgun, not a building block. Clear, intentional props force consistency.
Here's what a well-designed component interface looks like:
// components/Button.vue
<template>
<button
:class="buttonClasses"
:disabled="disabled"
:type="type"
@click="handleClick"
>
<span v-if="loading" class="spinner" />
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
type: 'button',
disabled: false,
loading: false,
fullWidth: false,
});
const emit = defineEmits<{
click: [event: MouseEvent];
}>();
const buttonClasses = computed(() => ({
btn: true,
[`btn--${props.variant}`]: true,
[`btn--${props.size}`]: true,
'btn--full-width': props.fullWidth,
'btn--loading': props.loading,
}));
const handleClick = (event: MouseEvent) => {
if (!props.disabled && !props.loading) {
emit('click', event);
}
};
</script>
<style scoped>
.btn {
padding: var(--space-sm) var(--space-md);
background-color: var(--color-primary);
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn--sm {
padding: var(--space-xs) var(--space-sm);
font-size: 0.875rem;
}
.btn--lg {
padding: var(--space-md) var(--space-lg);
font-size: 1.125rem;
}
.btn--secondary {
background-color: var(--color-neutral-200);
color: var(--color-neutral-900);
}
.btn--ghost {
background-color: transparent;
color: var(--color-primary);
border: 1px solid var(--color-primary);
}
.btn--danger {
background-color: var(--color-error);
}
.btn--full-width {
width: 100%;
}
.btn--loading {
pointer-events: none;
opacity: 0.7;
}
.spinner {
display: inline-block;
width: 1em;
height: 1em;
margin-right: var(--space-sm);
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>Notice the constraints: variant, size, and type are enums, not strings. Props like disabled have sensible defaults. The component doesn't accept class or style directly—this prevents teams from bypassing your design system by adding inline overrides. Keep your components opinionated.
For complex components like modals or dropdowns, document the expected behavior upfront. We use a props validation strategy:
interface ModalProps {
isOpen: boolean;
title: string;
onClose: () => void;
size?: 'sm' | 'md' | 'lg';
closeOnBackdropClick?: boolean;
}This forces consumers to think about accessibility, state management, and sizing from the start.
Document in Storybook or Histoire
Your design system is only as good as its documentation. If developers have to dig through source code to figure out what props a component accepts, they'll stop using it and write their own. Use Storybook or Histoire to document every component, variant, and state.
Here's a Storybook story for our Button:
// components/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: {
control: 'select',
options: ['primary', 'secondary', 'ghost', 'danger'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
disabled: {
control: 'boolean',
},
loading: {
control: 'boolean',
},
fullWidth: {
control: 'boolean',
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Click me',
},
};
export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Secondary action',
},
};
export const Loading: Story = {
args: {
loading: true,
children: 'Loading...',
},
};
export const Disabled: Story = {
args: {
disabled: true,
children: 'Disabled',
},
};
export const AllSizes: Story = {
render: () => ({
components: { Button },
template: `
<div style="display: flex; gap: 1rem;">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
`,
}),
};Storybook or Histoire gives you a living component catalog. Every time a designer questions whether a button should have a certain state, you point them to the docs. New developers onboard faster. And crucially, you have a testing sandbox to catch regressions before shipping.
Publish as a package
The final step: distribute your design system as an npm package. This might seem premature if you're building for a single app, but once you have three or more consuming projects—which happens faster than you think—centralization becomes essential.
Structure your package like this:
my-design-system/
├── package.json
├── tsconfig.json
├── src/
│ ├── components/
│ │ ├── Button.vue
│ │ ├── Input.vue
│ │ ├── Modal.vue
│ │ └── index.ts
│ ├── tokens/
│ │ ├── colors.ts
│ │ ├── spacing.ts
│ │ └── index.css
│ ├── utils/
│ │ ├── clsx.ts
│ │ └── index.ts
│ └── index.ts
├── .storybook/
└── README.mdYour package.json should expose the tokens and components clearly:
{
"name": "@myteam/design-system",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./dist/index.js",
"./tokens": "./dist/tokens/index.js",
"./tokens/css": "./dist/tokens/index.css",
"./components": "./dist/components/index.js"
},
"peerDependencies": {
"vue": "^3.3.0"
},
"devDependencies": {
"vue": "^3.3.0",
"@storybook/vue3": "^7.0.0"
}
}In consuming apps, install and use the design system tokens globally:
// nuxt.config.ts
export default defineNuxtConfig({
css: ['@myteam/design-system/tokens/css'],
modules: [],
});Then import components as needed:
// app.vue
<template>
<Button variant="primary" @click="handleSubmit">
Submit form
</Button>
</template>
<script setup>
import { Button } from '@myteam/design-system/components';
</script>Publish to npm or a private registry. Use semantic versioning: bump the patch version for bug fixes, minor for new components or props, major when breaking changes land. Communicate updates in a changelog so consuming teams know what's new.
Common mistakes
- Building components before tokens. Teams that skip the token layer end up with inconsistent spacing and color across components. Start with tokens, then build on top.
- Over-configurable components. A button that accepts arbitrary classes or inline styles is not a design system button. It's a footgun. Constrain your API.
- Shipping without documentation. A design system that no one understands is dead code. Invest time in Storybook stories, prop docs, and usage examples.
- Treating it as a one-time project. Design systems are living artifacts. Plan for regular maintenance, deprecations, and evolving patterns as your product grows.
- Ignoring accessibility from the start. Ensure every component has proper ARIA labels, keyboard navigation, and color contrast. Bake it in early; retrofitting is painful.
Key takeaways
- Start with design tokens as CSS variables, not hardcoded values in components. This enables single-source-of-truth updates and theme switching.
- Build base components with clear, constrained prop APIs. Enums over strings, sensible defaults, and no escape hatches for overrides.
- Document every component in Storybook or Histoire. A design system without docs is just code scattered across projects.
- Publish as an npm package once you have two or more consuming apps. Centralization prevents duplication and keeps your team aligned.
- Treat your design system as a product with stakeholders, versioning, and a maintenance cadence. It scales your team's ability to ship consistent, accessible UI.






Comments
Loading comments…